From ca4d3d283ceac5aacca28472437a6ca1bbf1e67a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sun, 23 Aug 2026 12:10:28 -0400 Subject: [PATCH] Add Pokemon Crystal as a sixth supported version Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes. --- CONTRIBUTING-mods.md | 30 +- README.md | 20 +- build-linux-arm-sbc.sh | 2 +- build-rg34xxsp.sh | 4 +- docs/mod-api-gen2-compat.md | 8 +- docs/new-features.md | 1 + docs/preparing-your-mod-for-gen2.md | 2 +- docs/switch-transfer.md | 8 +- scripts/build.sh | 5 +- scripts/build_android.sh | 55 +- scripts/linux-arm64/build_appimage.sh | 2 + .../linux-arm64/selftest_build_linux_arm64.sh | 2 + scripts/pack_love.sh | 3 +- scripts/run.sh | 3 +- scripts/switch/pack_sd_zip.sh | 14 +- scripts/switch/selftest_build_switch.sh | 4 +- scripts/test.sh | 64 +- src/battle/gen2/Battle.lua | 25 +- src/battle/gen2/Catching.lua | 42 + src/battle/gen2/Damage.lua | 31 +- src/battle/gen2/Mon.lua | 103 +- src/core/Game2.lua | 9 +- src/core/GameVersion.lua | 52 +- src/core/LaunchOptions.lua | 1 + src/core/SaveData.lua | 6 +- src/core/gen2/BattleTower.lua | 503 + src/core/gen2/Breeding.lua | 21 +- src/core/gen2/Evolution.lua | 6 + src/core/gen2/Happiness.lua | 23 +- src/core/gen2/Roamers.lua | 146 +- src/core/gen2/Save.lua | 91 +- src/debug/SwitchDiagnostics.lua | 5 +- src/import/CrystalMovie.lua | 396 + src/import/LauncherView.lua | 36 +- src/import/RomExtractorGen2.lua | 1122 +- src/import/RomImporter.lua | 84 +- src/render/MonAnim.lua | 164 + src/save_convert/SaveConvert.lua | 14 +- src/script/gen2/Opcodes.lua | 134 +- src/script/gen2/Specials.lua | 378 +- src/script/gen2/Vm.lua | 90 +- src/script/gen2/specials/battle_tower.lua | 397 + src/script/gen2/specials/crystal_extras.lua | 508 + src/script/gen2/specials/crystal_story.lua | 298 + src/script/gen2/specials/unown_words.lua | 29 + src/sync/SyncMods.lua | 2 +- src/ui/Screens.lua | 49 +- src/ui/gen2/BattleState.lua | 85 +- src/ui/gen2/BattleTowerMenu.lua | 220 + src/ui/gen2/BuenaPassword.lua | 170 + src/ui/gen2/CrystalIntro.lua | 1282 + src/ui/gen2/Diploma.lua | 6 +- src/ui/gen2/GenderSelect.lua | 139 + src/ui/gen2/HallOfFame.lua | 27 +- src/ui/gen2/MagnetTrainRide.lua | 21 +- src/ui/gen2/MoveTutor.lua | 163 + src/ui/gen2/NamePick.lua | 48 +- src/ui/gen2/NamingScreen.lua | 17 +- src/ui/gen2/OakSpeech.lua | 103 +- src/ui/gen2/PackMenu.lua | 19 +- src/ui/gen2/PhotoStudio.lua | 2 +- src/ui/gen2/Pokegear.lua | 164 +- src/ui/gen2/SummaryMenu.lua | 65 +- src/ui/gen2/TitleState.lua | 162 +- src/ui/gen2/TrainerCard.lua | 33 +- src/ui/gen2/UnownPrinter.lua | 2 +- src/ui/kit/Theme.lua | 5 +- src/world/gen2/FieldMoves.lua | 84 +- src/world/gen2/UnownWords.lua | 225 + src/world/gen2/World.lua | 239 +- tests/crystal_import_test.lua | 165 + tests/crystal_world_test.lua | 158 + tests/drivers/crystal_battle_tower_shots.lua | 173 + tests/drivers/crystal_boot_smoke.lua | 249 + tests/drivers/crystal_f2_save_and_vars.lua | 227 + tests/drivers/crystal_gender_flag_shots.lua | 260 + tests/drivers/crystal_gender_shots.lua | 234 + tests/drivers/crystal_intro_shots.lua | 117 + tests/drivers/crystal_story_shots.lua | 278 + tests/drivers/crystal_tower_battle_f1.lua | 277 + tests/drivers/crystal_unown_chambers.lua | 182 + tests/drivers/crystal_unown_words_shots.lua | 104 + tests/drivers/gate_chamber_fieldmoves.lua | 119 + tests/drivers/gate_gold_untouched.lua | 80 + tests/drivers/gate_tower_noshim.lua | 236 + .../engine/android_shortcuts_payload_test.lua | 10 +- tests/engine/crystal_version_test.lua | 218 + tests/engine/gate_gen2_mod_api.lua | 1 + tests/engine/gen2_script_opcodes_test.lua | 292 + tests/engine/launcher_gold_touch_rows.lua | 5 +- tests/engine/launcher_skins_tab.lua | 18 +- tests/engine/mod_targets_tests.lua | 32 +- .../rom_importer_nx_saves_inbox_test.lua | 3 +- tests/engine/uwp_baseroms_test.lua | 4 +- tests/gen2_badges_test.lua | 25 + tests/gen2_battle_tower_battle_test.lua | 603 + tests/gen2_battle_tower_test.lua | 629 + tests/gen2_battletype_escape_test.lua | 55 + tests/gen2_chamber_fieldmoves_test.lua | 71 + tests/gen2_crystal_anim_test.lua | 139 + tests/gen2_crystal_caught_data_test.lua | 314 + tests/gen2_crystal_extras_test.lua | 559 + tests/gen2_crystal_gender_flag_test.lua | 144 + tests/gen2_crystal_gender_test.lua | 334 + tests/gen2_crystal_mobile_gfx_test.lua | 463 + tests/gen2_crystal_story_test.lua | 555 + tests/gen2_crystal_townmap_test.lua | 421 + tests/gen2_crystal_vars_test.lua | 401 + tests/gen2_font_ui_test.lua | 47 +- tests/gen2_menus_test.lua | 9 +- tests/gen2_reflect_overflow_test.lua | 183 + tests/gen2_rom_text_test.lua | 18 +- tests/gen2_save_test.lua | 20 +- tests/gen2_unown_chambers_test.lua | 233 + tests/gen2_unown_printer_test.lua | 5 +- tests/gen2_unown_words_test.lua | 310 + tests/gen2_version_fixes_test.lua | 231 + tests/gen2_vm_test.lua | 8 +- tests/mod_ui_tests.lua | 2 +- tests/parity_hpcolor_og_palette.lua | 8 +- tests/parity_text_markers.lua | 12 +- tests/rom_importer_last_version_test.lua | 9 +- tests/run_gen2.lua | 50 + tests/run_tests.lua | 32 +- tests/save_editor_gen2_tests.lua | 398 +- tools/build_rom_data.py | 28 +- tools/crystal_movie_symbols.py | 72 + tools/crystal_symbol_deltas.py | 147 + tools/make_crystal_manifest.py | 170 + tools/make_gold_manifest.py | 238 +- tools/modkit.py | 6 +- tools/rom_data.py | 3 + tools/rom_manifest_crystal.json | 24663 ++++++++++++++++ tools/save-editor/App.lua | 9 +- tools/save-editor/Catalog.lua | 15 +- tools/save-editor/Gen.lua | 81 +- tools/save-editor/Gen2Flags.lua | 275 + tools/save-editor/Ops.lua | 70 + tools/save-editor/State.lua | 1 + tools/save-editor/panels/Items.lua | 39 +- tools/save-editor/panels/MonEditor.lua | 69 +- 141 files changed, 43306 insertions(+), 583 deletions(-) create mode 100644 src/core/gen2/BattleTower.lua create mode 100644 src/import/CrystalMovie.lua create mode 100644 src/render/MonAnim.lua create mode 100644 src/script/gen2/specials/battle_tower.lua create mode 100644 src/script/gen2/specials/crystal_extras.lua create mode 100644 src/script/gen2/specials/crystal_story.lua create mode 100644 src/script/gen2/specials/unown_words.lua create mode 100644 src/ui/gen2/BattleTowerMenu.lua create mode 100644 src/ui/gen2/BuenaPassword.lua create mode 100644 src/ui/gen2/CrystalIntro.lua create mode 100644 src/ui/gen2/GenderSelect.lua create mode 100644 src/ui/gen2/MoveTutor.lua create mode 100644 src/world/gen2/UnownWords.lua create mode 100644 tests/crystal_import_test.lua create mode 100644 tests/crystal_world_test.lua create mode 100644 tests/drivers/crystal_battle_tower_shots.lua create mode 100644 tests/drivers/crystal_boot_smoke.lua create mode 100644 tests/drivers/crystal_f2_save_and_vars.lua create mode 100644 tests/drivers/crystal_gender_flag_shots.lua create mode 100644 tests/drivers/crystal_gender_shots.lua create mode 100644 tests/drivers/crystal_intro_shots.lua create mode 100644 tests/drivers/crystal_story_shots.lua create mode 100644 tests/drivers/crystal_tower_battle_f1.lua create mode 100644 tests/drivers/crystal_unown_chambers.lua create mode 100644 tests/drivers/crystal_unown_words_shots.lua create mode 100644 tests/drivers/gate_chamber_fieldmoves.lua create mode 100644 tests/drivers/gate_gold_untouched.lua create mode 100644 tests/drivers/gate_tower_noshim.lua create mode 100644 tests/engine/crystal_version_test.lua create mode 100644 tests/engine/gen2_script_opcodes_test.lua create mode 100644 tests/gen2_battle_tower_battle_test.lua create mode 100644 tests/gen2_battle_tower_test.lua create mode 100644 tests/gen2_battletype_escape_test.lua create mode 100644 tests/gen2_chamber_fieldmoves_test.lua create mode 100644 tests/gen2_crystal_anim_test.lua create mode 100644 tests/gen2_crystal_caught_data_test.lua create mode 100644 tests/gen2_crystal_extras_test.lua create mode 100644 tests/gen2_crystal_gender_flag_test.lua create mode 100644 tests/gen2_crystal_gender_test.lua create mode 100644 tests/gen2_crystal_mobile_gfx_test.lua create mode 100644 tests/gen2_crystal_story_test.lua create mode 100644 tests/gen2_crystal_townmap_test.lua create mode 100644 tests/gen2_crystal_vars_test.lua create mode 100644 tests/gen2_reflect_overflow_test.lua create mode 100644 tests/gen2_unown_chambers_test.lua create mode 100644 tests/gen2_unown_words_test.lua create mode 100644 tests/gen2_version_fixes_test.lua create mode 100644 tests/run_gen2.lua create mode 100644 tools/crystal_movie_symbols.py create mode 100644 tools/crystal_symbol_deltas.py create mode 100644 tools/make_crystal_manifest.py create mode 100644 tools/rom_manifest_crystal.json create mode 100644 tools/save-editor/Gen2Flags.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 3030138f..7a2f525d 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -140,8 +140,8 @@ ship text. ### 4. `games` (and the legacy `gen2compat`) -Pokemon Gold and Silver are Gen 2, and they run their own battle engine, -overworld, script VM and save format. The mod API is shared across both +Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle +engine, overworld, script VM and save format. The mod API is shared across both generations (same hook names, same event names, same registry names) but Gen 2 cannot serve all of it yet, so it is opt-in. Say which games the mod is for: @@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for: ``` Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`, -`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`; +`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`; `src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing -restates the game list. `python3 tools/modkit.py scaffold my_mod --games -gen1,gen2` writes the key for you. The mod still installs to one directory, -`mods//`, shared by every game -- targeting is declared, never filed. +restates the game list, which is why `"gen2"` covers Crystal as well as Gold +and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes +the key for you. The mod still installs to one directory, `mods//`, shared +by every game -- targeting is declared, never filed. Absent means Gen 1 only, which is what every mod written before the key existed was tested as. `"gen2compat": true` is the legacy spelling, still accepted and purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it -already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not -loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, -because a mod that half-applies reads as a broken mod. Claim Gen 2 once you -have actually run your mod on Gold or Silver. +already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at +all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a +mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually +run your mod on Gold, Silver or Crystal. Every token is enforced, per game: the loader gates on the same `ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]` @@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written before the key existed changes behavior; list both generations or say `"all"` when you mean everywhere. -`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold -and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1, -and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their -writes with a report, and which hooks and events are still to come. +`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold, +Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names +shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home +and drop their writes with a report, and which hooks and events are still to +come. `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a Gen 1 mod, and it is the one to start from. diff --git a/README.md b/README.md index f213eb27..d769fb0d 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * [![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) This project does not include a ROM, emulate the Game Boy, transpile assembly, -or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or -Silver ROM is the only game content input. +or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, +Silver, or Crystal ROM is the only game content input. The ROM is verified, used during import, and then released from memory. It is not copied into the cache. Later launches load the private generated cache and -do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be -imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher; -see `docs/gold-phase1.md`): the Gen 2 engine is still under construction. +do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can +all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1 +(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under +construction, and Crystal is the newest of the three, so the launcher lists it +as Crystal (Beta). ## Quick Start @@ -71,14 +73,16 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` / `.gbc` file or drop it onto the window. Import takes a few seconds and the game starts automatically. -Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB) -ROMs are accepted. The importer verifies SHA-1 before creating any game data: +Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal +(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any +game data: - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` - Silver: `49b163f7e57702bc939d642a18f591de55d92dae` +- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133` The packaged app contains neither a ROM nor pre-extracted game data. Music, sound effects, and cries are synthesized while the game runs from compact @@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | Option | Effect | | --- | --- | -| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) | +| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) | | `--slot=2` | load that save slot; takes a slot number or a slot id | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | diff --git a/build-linux-arm-sbc.sh b/build-linux-arm-sbc.sh index 0884f27d..43ee86a1 100755 --- a/build-linux-arm-sbc.sh +++ b/build-linux-arm-sbc.sh @@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC" main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if unzip -Z1 "$WORK/game-payload.zip" \ | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 3702d226..bfa0f8c4 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC" main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') payload_list="$(unzip -Z1 "$WORK/game-payload.zip")" printf '%s\n' "$payload_list" \ @@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \ || fail "payload is missing tools/rom_manifest_gold.json" printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \ || fail "payload is missing tools/rom_manifest_silver.json" +printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \ + || fail "payload is missing tools/rom_manifest_crystal.json" unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC" rm -f "$WORK/game-payload.zip" diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 716a2607..54f94243 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -55,10 +55,10 @@ The short version, for an author deciding what to write: ``` `games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`, -`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or -`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` -and `GameVersion.generation`, so nothing anywhere restates the game list. -`"gen2"` now expands to both Gold and Silver. +`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`, +case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off +`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates +the game list. `"gen2"` now expands to Gold, Silver and Crystal. `Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games` and **derives** `manifest.gen2compat` from them, which is the one field the loader's gate reads. diff --git a/docs/new-features.md b/docs/new-features.md index 7d5f43f5..dccc76ea 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -19,5 +19,6 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow ## Gen 2 Specifics * **Pokémon Silver** as an importable, launcher-selectable version alongside Gold +* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 382a3d21..486e6b6a 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -176,7 +176,7 @@ something the filesystem encodes. | token | means | | --- | --- | -| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) | +| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) | | `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) | | `"all"` | every game this engine has | diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md index 798a8f85..ed363464 100644 --- a/docs/switch-transfer.md +++ b/docs/switch-transfer.md @@ -22,8 +22,8 @@ Player install (what to download, title override) stays in | Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it | | ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card//imports/`) | | Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** | -| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) | -| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) | +| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) | +| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` | | Lua error log | `lua-error.log` in the save dir | @@ -54,8 +54,8 @@ macOS, not a Mac-only requirement. 3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root (or copy NRO / `game.love` for loose). 4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`, - `imports/saves//`, or - `exports//` + `imports/saves//`, or + `exports//` path the launcher prints. 5. Wait for the queue; refresh; exit MTP responder; title-override launch. diff --git a/scripts/build.sh b/scripts/build.sh index 10b3aec8..3861b84e 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -79,7 +79,7 @@ else main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') fi # Materialize the listing once and grep the file: piping unzip straight into @@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ src/ui/kit/Kit.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json; do + tools/rom_manifest_silver.json \ + tools/rom_manifest_crystal.json; do grep -qxF "$required" "$LOVE_LISTING" \ || fail "game.love is missing $required" done diff --git a/scripts/build_android.sh b/scripts/build_android.sh index e5b508a5..4389548f 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -34,6 +34,8 @@ GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json" GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}" SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json" SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}" +CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json" +CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}" VERSION="" PACKAGE_ONLY=false @@ -246,6 +248,54 @@ ensure_silver_manifest() { fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL" } +crystal_manifest_is_valid() { + local path="$1" + python3 - "$path" <<'PY' +import json, pathlib, sys + +try: + manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +except (OSError, ValueError): + raise SystemExit(1) + +raise SystemExit(0 if manifest.get("romSha1") == + "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1) +PY +} + +ensure_crystal_manifest() { + local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE" + local staged + staged="$(mktemp)" + + if crystal_manifest_is_valid "$manifest"; then + rm -f "$staged" + return + fi + + warn "Crystal import manifest is missing or invalid; recovering it before packaging" + if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \ + && crystal_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "restored Crystal import manifest from this checkout's Git data" + return + fi + + if command -v curl >/dev/null 2>&1 \ + && curl --fail --location --retry 2 --connect-timeout 15 \ + --output "$staged" "$CRYSTAL_MANIFEST_URL" \ + && crystal_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "downloaded Crystal import manifest from the project repository" + return + fi + + rm -f "$staged" + fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL" +} + # --------------------------------------------------------------- branding # love-android 11.5+ reads app id / name / orientation from gradle.properties. # Manifest still gets permission trims. Re-applied every build so refreshing @@ -312,6 +362,7 @@ pack_game_love() { ensure_yellow_manifest ensure_gold_manifest ensure_silver_manifest + ensure_crystal_manifest mkdir -p "$EMBED_ASSETS" rm -f "$LOVE_FILE" # tools/save-editor ships with the app: the launcher's Edit button on a save @@ -326,7 +377,7 @@ pack_game_love() { main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x 'data/generated/*' -x 'assets/generated/*') # List once and match against the captured text: piping unzip straight into @@ -350,6 +401,8 @@ pack_game_love() { || fail "game.love is missing the Gold ROM import manifest" grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \ || fail "game.love is missing the Silver ROM import manifest" + grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \ + || fail "game.love is missing the Crystal ROM import manifest" # This gate exists because the launcher's UI toolkit once lived outside # src/ (libs/flexlove) and was added to scripts/build.sh's payload and to # no other packager, so Android and iOS built an APK/IPA whose launcher diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 59d59a71..1c6af56d 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \ || fail "game.love is missing tools/rom_manifest_gold.json" grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \ || fail "game.love is missing tools/rom_manifest_silver.json" +grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \ + || fail "game.love is missing tools/rom_manifest_crystal.json" # The .desktop's Icon= resolves against the AppDir root by basename, and # .DirIcon is what appimaged and file-manager thumbnailers read. cp "$IN/icon.png" "$APPDIR/$APP_NAME.png" diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh index 8320222b..47fc5e59 100755 --- a/scripts/linux-arm64/selftest_build_linux_arm64.sh +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \ || fail "shared payload is missing tools/rom_manifest_gold.json" grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \ || fail "shared payload is missing tools/rom_manifest_silver.json" +grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \ + || fail "shared payload is missing tools/rom_manifest_crystal.json" say "Linux arm64 self-test passed" diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index a32653b5..7136088d 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -50,7 +50,7 @@ rm -f "$OUTPUT" main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if [ -f "$ROOT/PATCH_NOTES.md" ]; then (cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md) @@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_silver.json \ + tools/rom_manifest_crystal.json \ src/ui/kit/Kit.lua \ src/import/LauncherView.lua; do grep -qxF "$required" "$LISTING" \ diff --git a/scripts/run.sh b/scripts/run.sh index 6cfefde1..cf89e593 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \ - && [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then + && [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then fail "generated data missing, run scripts/setup.sh first" fi diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh index 1d0478a9..d6277899 100755 --- a/scripts/switch/pack_sd_zip.sh +++ b/scripts/switch/pack_sd_zip.sh @@ -71,15 +71,15 @@ First install or update (same steps): your saves, imported ROMs, mods, and options. Re-extracting only replaces the NRO(s) and these help files. 3. Launch with title override (hold R on HOME, open any title → hbmenu). - 4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into: + 4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into: switch/gen1recomp/pokemon-love2d/imports/ then use Scan again in the launcher if needed. Inboxes (drop files here via MTP / SD / FTP): imports/ — ROM .gb / .gbc imports/mods/ — community mod .zip - imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet) - exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet) + imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet) + exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet) Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md EOF @@ -92,7 +92,7 @@ write_readme() { } write_readme "$SAVE_ROOT/imports/README.txt" \ - "Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher." + "Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher." write_readme "$SAVE_ROOT/imports/mods/README.txt" \ "Put community mod .zip files here, then MODS → Scan again." write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \ @@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \ "Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \ "Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games." +write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \ + "Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/exports/red/README.txt" \ "After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP." write_readme "$SAVE_ROOT/exports/blue/README.txt" \ @@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \ "Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/exports/silver/README.txt" \ "Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games." +write_readme "$SAVE_ROOT/exports/crystal/README.txt" \ + "Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games." rm -f "$OUT_ZIP" ( @@ -145,11 +149,13 @@ REQUIRED=( "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" "switch/gen1recomp/pokemon-love2d/exports/red/README.txt" "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt" ) for rel in "${REQUIRED[@]}"; do printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel" diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh index 946a1107..88fa4846 100755 --- a/scripts/switch/selftest_build_switch.sh +++ b/scripts/switch/selftest_build_switch.sh @@ -272,11 +272,13 @@ for rel in \ "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \ + "switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \ - "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \ + "switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt" do printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}" done diff --git a/scripts/test.sh b/scripts/test.sh index 57be8d0a..71760e19 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,8 +5,8 @@ # them fails. The tier split is what makes that possible: T1/T2/T4 need # nothing but the committed fixture dataset, so they run anywhere -- # including CI, which has no ROM. T3 asserts Pokemon Red facts and needs -# data/generated/, so it is skipped automatically when the ROM has never -# been imported rather than failing the run. +# data/generated/ or an imported Red cache, so it is skipped automatically +# when neither exists rather than failing the run. # # scripts/test.sh every tier this checkout can run # scripts/test.sh --quick skip the slow content tier @@ -16,6 +16,13 @@ # # LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but # the engine targets LuaJIT/5.1 semantics so luajit is the default). +# +# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache +# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as +# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with: +# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION= \ +# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="" love . +# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins. set -uo pipefail @@ -32,7 +39,7 @@ for arg in "$@"; do --bless) BLESS=1 ;; --bless-shots) SHOTS=1; BLESS=1 ;; --quick) QUICK=1 ;; - --help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done @@ -49,6 +56,36 @@ fi # save-editor suite pins that to the default identity. SANDBOX_IDENTITY="ci-$$" +# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an +# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/). +CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches} +if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then + CACHE_ROOT="$POKEPORT_TEST_CACHES" +elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then + CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" +else + CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY" +fi + +# Only a cache with the importer's completion marker is offered: a half-written +# one would fail suites that are meant to self-skip. +adopt_cache() { + local var="$1" dir="$CACHE_ROOT/$2" + [ -z "${!var:-}" ] || return 0 + [ -f "$dir/rom-cache.complete" ] || return 0 + export "$var=$dir" + echo " $var=$dir" +} + +echo "" +echo "-- per-version caches under $CACHE_ROOT" +adopt_cache RED_CACHE red +adopt_cache BLUE_CACHE blue +adopt_cache YELLOW_CACHE yellow +adopt_cache GOLD_CACHE gold +adopt_cache SILVER_CACHE silver +adopt_cache CRYSTAL_CACHE crystal + FAILED=() run_tier() { local label="$1"; shift @@ -77,6 +114,7 @@ fi run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py +run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a @@ -87,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_ run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua +# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache), +# so it runs here rather than behind the Red content gate below. +run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 title checkpoint cold restart" \ bash tests/integration/title_checkpoint_cold_start.sh @@ -133,7 +174,19 @@ run_content_behavior() { return 1 } +# The Red dataset is the source tree's when tools/build_data.py wrote one, and +# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook. +HAVE_RED_DATA=0 if [ -f data/generated/maps.lua ]; then + HAVE_RED_DATA=1 +elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then + HAVE_RED_DATA=1 + export POKEPORT_DATA_DIR="$RED_CACHE/data/generated" + echo "" + echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR" +fi + +if [ "$HAVE_RED_DATA" = "1" ]; then if [ "$QUICK" = "1" ]; then echo "" echo "-- T3 content: skipped (--quick)" @@ -163,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then fi else echo "" - echo "-- T3 content + run_link_tests: skipped (no data/generated/ --" - echo " import a ROM to run them; the modded-link cases ran in T4)" + echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no" + echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran" + echo " in T4 and the Gen 2 suites in T2)" fi # ------- golden screenshots: needs love + a display diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index a9da9619..2cc93065 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = { -- be run from or Roared away. Battle.BATTLETYPE_FORCESHINY = 7 Battle.BATTLETYPE_TRAP = 9 +-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends +Battle.BATTLETYPE_CELEBI = 11 +Battle.BATTLETYPE_SUICUNE = 12 -- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle -- type whose loss still prints the trainer's own line instead of a whiteout. Battle.BATTLETYPE_CANLOSE = 1 @@ -247,6 +250,10 @@ function Battle.new(opts) -- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the -- FORCESHINY / TRAP no-escape rules will hang off the same field. self.battleType = opts.battleType + -- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set + -- around the Tower's own StartBattle (engine/events/battle_tower/ + -- battle_tower.asm:220-223) and cleared again at :253-254. + self.inBattleTowerBattle = opts.battleTower and true or false self.events = {} self.turn = 0 self.over = false @@ -765,6 +772,9 @@ end function Battle:battleStat(mon, key) local value = (mon.stats or {})[key] or 1 if mon ~= self.player then return value end + -- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788): + -- adventure badges do not follow the player into the standardised Tower. + if self.inBattleTowerBattle then return value end local badge = Battle.BADGE_STAT_BOOSTS[key] if badge and self:hasBadge("badges", badge) then return Battle.boostStat(value) @@ -783,6 +793,8 @@ end -- boosts the damage. Each type appears once, so this is a plain scan. function Battle:badgeTypeBoost(attacker, moveType) if attacker ~= self.player or not moveType then return false end + -- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154). + if self.inBattleTowerBattle then return false end for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do if row.type == moveType then return self:hasBadge(row.store, row.badge) @@ -3965,6 +3977,16 @@ function Battle:usableMoves(mon) return out end +-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI, +-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third. +function Battle:noEscapeBattleType() + local t = self.battleType + return t == Battle.BATTLETYPE_FORCESHINY + or t == Battle.BATTLETYPE_TRAP + or t == Battle.BATTLETYPE_CELEBI + or t == Battle.BATTLETYPE_SUICUNE +end + -- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are -- based on the speed ratio and how many times you have tried this battle. -- Trainers never let you run. @@ -3978,8 +4000,7 @@ function Battle:tryRun(pSpd) -- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the -- trainer check and any speed math. Without this, running from the Red -- Gyarados returned a WIN to the script and forfeited the one-shot shiny. - if self.battleType == Battle.BATTLETYPE_FORCESHINY - or self.battleType == Battle.BATTLETYPE_TRAP then + if self:noEscapeBattleType() then self:emit({ kind = "message", text = "Can't escape!" }) self.runRefused = true return false diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index 8284add5..625159d4 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -25,6 +25,7 @@ -- names src/battle/BattleState.lua raises on Gen 1, with the same argument -- order and the same payload keys (docs/mod-api-gen2-compat.md). local Runtime = require("src.mods.Runtime") +local Mon = require("src.battle.gen2.Mon") local Catching = {} @@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts) return bonuses[status] or 0 end +-- constants/landmark_constants.asm:24, the fallback when no cache is passed. +Catching.LANDMARK_NATIONAL_PARK = 19 + +local function landmarkIndex(opts, id, fallback) + local data = opts and (opts.data or (opts.battle and opts.battle.data)) + local rows = (opts and opts.landmarks) + or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks) + local row = rows and rows[id] + return (row and row.index) or fallback +end + +-- GetWorldMapLocation with the POKECENTER_2F backup-map swap +-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81). +function Catching.caughtLandmark(opts) + opts = opts or {} + if opts.bugContest then + return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK", + Catching.LANDMARK_NATIONAL_PARK) + end + if opts.landmark then return opts.landmark end + local map = opts.map + if map and map.id == "POKECENTER_2F" and opts.backupMap then + map = opts.backupMap + end + return (map and map.landmark) or 0 +end + +-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal. +function Catching.stampCaughtData(mon, opts) + opts = opts or {} + if not Mon.hasCaughtData(opts.version) then return mon end + local save = opts.save or (opts.battle and opts.battle.save) + return Mon.setCaughtData(mon, { + level = opts.level or (type(mon) == "table" and mon.level) or 0, + timeOfDay = opts.timeOfDay, + landmark = Catching.caughtLandmark(opts), + playerGender = opts.playerGender + or (save and save.player and save.player.gender), + }) +end + -- Does the ball catch? Returns caught and the final rate (wFinalCatchRate). -- A rate of 255 or a Master Ball is certain. -- diff --git a/src/battle/gen2/Damage.lua b/src/battle/gen2/Damage.lua index 58a2b112..df97a1d4 100644 --- a/src/battle/gen2/Damage.lua +++ b/src/battle/gen2/Damage.lua @@ -21,8 +21,13 @@ -- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's -- records carry that as `category`. +local GameVersion = require("src.core.GameVersion") + local Damage = {} +-- ../pokecrystal/constants/battle_constants.asm:78 +Damage.MAX_STAT_VALUE = 999 + -- data/battle/critical_hit_chances.asm, as "1 in N". Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 } @@ -60,7 +65,23 @@ end function Damage.applyStage(value, stage) local numerator, denominator = Damage.stageMultiplier(stage) local out = math.floor(value * numerator / denominator) - return math.max(1, out) + -- ../pokecrystal/engine/battle/core.asm:6739 + return math.max(1, math.min(Damage.MAX_STAT_VALUE, out)) +end + +-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one +-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it. +function Damage.truncateStats(attack, defense, fixed) + local a = math.max(0, math.floor(attack or 0)) + local d = math.max(0, math.floor(defense or 0)) + while a > 255 or d > 255 do + d = math.floor(d / 4) + if d == 0 then d = 1 end + a = math.floor(a / 4) + if a == 0 then a = 1 end + if not fixed then break end + end + return a % 256, d % 256 end -- Is this move physical? `types` is type_chart.lua's `types` table. @@ -169,6 +190,8 @@ end -- random(n) -- 0..n-1, for the variation roll -- screen -- Reflect/Light Screen active on the defender -- defenseHalved -- EFFECT_SELFDESTRUCT's srl c +-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC +-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644 -- -- Returns damage, info where info carries the pieces a battle message needs: -- effectiveness (x10), critical, physical, variation. @@ -204,6 +227,12 @@ function Damage.calc(opts) defense = defense * 2 end + -- PlayerAttackDamage hands DamageCalc one-byte stats + -- (../pokecrystal/engine/battle/effect_commands.asm:2604). + local fixed = opts.reflectOverflowFixed + if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end + attack, defense = Damage.truncateStats(attack, defense, fixed) + -- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and -- Explosion halve the defence, never below 1. if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 5f515567..ca7d363e 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -17,6 +17,7 @@ -- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per -- GROWTH_* so this needs no hardcoded table. +local GameVersion = require("src.core.GameVersion") local Unown = require("src.core.gen2.Unown") -- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME -- names src/battle/Experience.lua and src/battle/BattleState.lua raise on @@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6 -- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess. Mon.MAX_DV = 15 +-- MON_CAUGHTDATA's two packed bytes and their masks -- +-- constants/pokemon_data_constants.asm:93-99, :120-130. +Mon.CAUGHT_TIME_MASK = 0xc0 +Mon.CAUGHT_LEVEL_MASK = 0x3f +Mon.CAUGHT_GENDER_MASK = 0x80 +Mon.CAUGHT_LOCATION_MASK = 0x7f +Mon.CAUGHT_EGG_LEVEL = 1 +-- constants/landmark_constants.asm:111-113 +Mon.LANDMARK_EVENT = 0x7f +Mon.LANDMARK_GIFT = 0x7e + +-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData -- +-- pokegold constants/pokemon_data_constants.asm:93. +function Mon.hasCaughtData(version) + return GameVersion.engine(version) == "crystal" +end + +-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored +-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172. +local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 } + +function Mon.caughtTimeOf(timeOfDay) + if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end + if type(timeOfDay) ~= "number" then return 0 end + local id = math.floor(timeOfDay) + if id < 0 or id > 3 then return 0 end + if id > 2 then id = 2 end + return id + 1 +end + +-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177). +local CAUGHT_GENDER = { + girl = "girl", female = "girl", boy = "boy", male = "boy", +} + +function Mon.caughtGenderOf(gender) + if gender == true then return "girl" end + if gender == false then return "boy" end + if type(gender) ~= "string" then return nil end + return CAUGHT_GENDER[gender:lower()] +end + +local function landmarkByte(landmark) + if type(landmark) ~= "number" then return 0 end + return math.floor(landmark) % 0x80 +end + +-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the +-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242). +function Mon.setCaughtData(mon, opts) + if type(mon) ~= "table" then return mon end + opts = opts or {} + mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay) + mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0)) + mon.caughtLocation = landmarkByte(opts.landmark) + mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy" + return mon +end + +-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b` +-- (constants/pokemon_data_constants.asm:126-128). +Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 } + +-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or +-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit. +function Mon.setGiftCaughtData(mon, caughtBy) + if type(mon) ~= "table" then return mon end + local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0 + local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK + local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated) + mon.caughtTime, mon.caughtLevel = 0, 0 + mon.caughtLocation = unpacked.caughtLocation + mon.caughtByGender = unpacked.caughtByGender + return mon +end + +-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it. +function Mon.packCaughtData(mon) + mon = type(mon) == "table" and mon or {} + local time = math.floor(tonumber(mon.caughtTime) or 0) % 4 + local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40 + local location = landmarkByte(tonumber(mon.caughtLocation) or 0) + local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl" + return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location +end + +function Mon.unpackCaughtData(byte0, byte1) + byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256 + return { + caughtTime = math.floor(byte0 / 0x40), + caughtLevel = byte0 % 0x40, + caughtLocation = byte1 % 0x80, + caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy", + } +end + local function rand(a, b) if love and love.math and love.math.random then return love.math.random(a, b) @@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts) status = nil, -- 70 for a caught mon, 120 for a gift/hatched one. happiness = opts.happiness or 70, - caughtLevel = level, + caughtLevel = opts.caughtLevel or level, + -- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99. + caughtTime = opts.caughtTime, + caughtLocation = opts.caughtLocation, + caughtByGender = opts.caughtByGender, -- shiny.roll / gender.roll get the species and level as context; opts.shiny -- still wins, because a FORCED shiny battle (Red Gyarados) is the cart -- overriding the roll rather than a roll to be hooked. diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4ab8a479..176ee13f 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock") local FixedStep = require("src.core.FixedStep") local Font = require("src.render.Font") local GamepadMap = require("src.core.GamepadMap") +local GameVersion = require("src.core.GameVersion") local Input = require("src.core.Input") local Music = require("src.core.Music") local Save = require("src.core.gen2.Save") @@ -288,8 +289,6 @@ function Game2:continueGame(save) self:startWorld() -- After the adopt and after the world is standing, which is where Gen 1 -- emits it (src/core/Game.lua:1127, once the stack has been rebuilt). - -- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff - -- is derived from it and so comes back empty rather than missing. if modsDiff then local notice = SaveData.modsDiffNotice(modsDiff, save.meta) if notice then require("src.core.Logger").warn("%s", notice) end @@ -354,10 +353,14 @@ function Game2:showTitle() }) end +-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own +-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro. function Game2:showIntro() self.stack:clear() self.phase = "boot" - Screens.push(self, "Gen2GoldSilverIntro", { + local id = (GameVersion.engine() == "crystal") + and "Gen2CrystalIntro" or "Gen2GoldSilverIntro" + Screens.push(self, id, { onDone = function() self:showTitle() end, diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index b6435b86..e9b57cc7 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1,6 +1,6 @@ -- Which game this process is running: Red (the historical default), Blue, --- Yellow, Gold, or Silver. One source of truth for everything that differs by --- version -- the accepted ROM hash, the import manifest, where the +-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that +-- differs by version -- the accepted ROM hash, the import manifest, where the -- extracted cache lives, and the save-file suffix -- so the importer, -- cache mount, SaveData, title screen and palette all agree. -- @@ -8,8 +8,9 @@ -- saves are untouched, but its extracted cache lives under red/ like Blue, -- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once -- by CacheFs.migrateLegacyRedCache. All supported versions can be imported --- and selected side by side. Gold and Silver are Gen 2 (see --- docs/gold-phase1.md). +-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see +-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine` +-- splits Gold/Silver from Crystal within Gen 2. -- -- Zero requires, so it loads during love.conf and under plain Lua for tools -- and tests. The active version is a process-global set once at boot from @@ -27,6 +28,8 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest.json", cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899) saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp + -- Absent reads as "gen1" (GameVersion.engine) + engine = "gen1", }, blue = { id = "blue", @@ -37,6 +40,7 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest_blue.json", cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp + engine = "gen1", }, yellow = { id = "yellow", @@ -47,6 +51,7 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest_yellow.json", cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp + engine = "gen1", }, -- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of -- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of @@ -64,6 +69,7 @@ GameVersion.VERSIONS = { saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp -- Absent reads as 1 (GameVersion.generation) generation = 2, + engine = "gs", }, -- Gold's engine with edition-selected data; the manifest is derived from -- Gold's by tools/make_silver_manifest.py. @@ -77,11 +83,34 @@ GameVersion.VERSIONS = { cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp generation = 2, + engine = "gs", + }, + crystal = { + id = "crystal", + label = "Crystal", + displayName = "Pokemon Crystal", + launcherName = "Crystal (Beta)", + sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + manifest = "tools/rom_manifest_crystal.json", + cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated + saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp + generation = 2, + engine = "crystal", + fixes = { + -- pokegold/docs/bugs_and_glitches.md:61 + luckyNumberBoxes = true, + -- pokegold/docs/bugs_and_glitches.md:88 + surfOntoNpc = true, + -- pokecrystal/engine/battle/effect_commands.asm:2614 + reflectOverflow = true, + }, }, } --- Launcher column order. -GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" } +local NO_FIXES = {} + +-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index). +GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" } GameVersion.current = "red" @@ -115,6 +144,17 @@ function GameVersion.generation(id) return GameVersion.info(id).generation or 1 end +-- "gen1" | "gs" | "crystal": lineage within a generation. +function GameVersion.engine(id) + return GameVersion.info(id).engine or "gen1" +end + +-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged. +function GameVersion.fixes(id) + local info = GameVersion.info(id) + return (info and info.fixes) or NO_FIXES +end + -- Metadata for a version id, defaulting to the active one. function GameVersion.info(id) return GameVersion.VERSIONS[id or GameVersion.current] diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 7317ba8a..18a692b2 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -40,6 +40,7 @@ local function normalizeVersion(v) y = "yellow", yellow = "yellow", g = "gold", gold = "gold", s = "silver", silver = "silver", + c = "crystal", crystal = "crystal", } v = alias[v] or v if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 53e44f3c..514bd433 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1486,8 +1486,10 @@ function SaveData.runMigrations(save, modChains, activeMods) -- every step whose from-format the save has not passed yet runs, in -- (from, registration) order; a save at the current format runs none local fmt = (save.meta and save.meta.format) or 1 - for _, m in ipairs(coreMigrations) do - if m.from >= fmt then m.fn(save) end + if GameVersion.generation() == 1 then + for _, m in ipairs(coreMigrations) do + if m.from >= fmt then m.fn(save) end + end end -- a save that predates meta records an empty mod set: an old vanilla -- save becomes a v2 vanilla save diff --git a/src/core/gen2/BattleTower.lua b/src/core/gen2/BattleTower.lua new file mode 100644 index 00000000..9a18cda9 --- /dev/null +++ b/src/core/gen2/BattleTower.lua @@ -0,0 +1,503 @@ +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and +-- ../pokecrystal/engine/events/battle_tower/rules.asm:27. + +local Breeding = require("src.core.gen2.Breeding") +local HallOfFame = require("src.core.gen2.HallOfFame") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") + +local BattleTower = {} + +-- ../pokecrystal/constants/battle_tower_constants.asm:1-7 +BattleTower.PARTY_LENGTH = 3 +BattleTower.STREAK_LENGTH = 7 +BattleTower.NUM_UNIQUE_MON = 21 +BattleTower.NUM_UNIQUE_TRAINERS = 70 +BattleTower.TRAINERDATALENGTH = 36 + +-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE +BattleTower.GS_BALL_AVAILABLE = 0x0b + +-- ../pokecrystal/constants/battle_tower_constants.asm:55-61 +BattleTower.NO_CHALLENGE = 0 +BattleTower.SAVED_AND_LEFT = 1 +BattleTower.CHALLENGE_IN_PROGRESS = 2 +BattleTower.WON_CHALLENGE = 3 +BattleTower.RECEIVED_REWARD = 4 + +-- ../pokecrystal/constants/battle_tower_constants.asm:63-67 +BattleTower.REWARD_QUANTITY = 5 +BattleTower.MIN_REWARD = "HP_UP" +BattleTower.MAX_REWARD = "CALCIUM" +BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH" +BattleTower.FALLBACK_REWARD = "POTION" + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0, +-- :978-1008 bit 1 of sBattleTowerSaveFileFlags. +BattleTower.SAVEFILE_REGISTERED = 1 +BattleTower.SAVEFILE_EXPLANATION = 2 + +-- ../pokecrystal/constants/battle_tower_constants.asm:11-43 +BattleTower.ACTIONS = { + CHECK_EXPLANATION_READ = 0, + SET_EXPLANATION_READ = 1, + GET_CHALLENGE_STATE = 2, + SAVE_AND_QUIT = 3, + CHALLENGECANCELED = 4, + ACTION_05 = 5, + ACTION_06 = 6, + SAVELEVELGROUP = 7, + LOADLEVELGROUP = 8, + CHECKSAVEFILEISYOURS = 9, + ACTION_0A = 10, + GSBALL = 11, + ACTION_0C = 12, + ACTION_0D = 13, + EGGTICKET = 14, + ACTION_0F = 15, + ACTION_10 = 16, + ACTION_11 = 17, + ACTION_12 = 18, + ACTION_13 = 19, + ACTION_14 = 20, + ACTION_15 = 21, + ACTION_16 = 22, + ACTION_17 = 23, + LEVEL_CHECK = 24, + UBERS_CHECK = 25, + RESETDATA = 26, + GIVEREWARD = 27, + ACTION_1C = 28, + ACTION_1D = 29, + CHOOSEREWARD = 30, + SAVEOPTIONS = 31, +} +BattleTower.NUM_ACTIONS = 32 + +-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which +-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`. +BattleTower.WRAM_NR_BEATEN = 0xcf64 + +-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`. +BattleTower.TEXT_INTRO = 1 +BattleTower.TEXT_WIN = 2 +BattleTower.TEXT_LOSS = 3 + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order +-- BattleTower_ExecuteJumptable prints them. +BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText" +BattleTower.RULE_FAIL_TEXTS = { + "_OnlyThreeMonMayBeEnteredText", + "_TheMonMustAllBeDifferentKindsText", + "_TheMonMustNotHoldTheSameItemsText", + "_YouCantTakeAnEggText", +} +-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68 +BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText" +-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2. +BattleTower.RULE_PARTY_COUNT_TEXT = "3" + +-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck. +BattleTower.UBERS = { + MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true, +} +BattleTower.UBER_MIN_LEVEL = 70 + +-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and +-- Strings_Ll0ToL40. +BattleTower.MAX_LEVEL_GROUP = 10 +BattleTower.PRE_HOF_LEVEL_GROUPS = 4 + +local function counter(value) + return math.max(0, math.floor(tonumber(value) or 0)) +end + +-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState. +function BattleTower.state(save) + local tower = Save.battleTowerState(save) + -- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup + tower.levelGroup = counter(tower.levelGroup) + -- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags + tower.saveFileFlags = counter(tower.saveFileFlags) % 256 + -- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot + if type(tower.trainers) ~= "table" then tower.trainers = {} end + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d + if tower.reentry == nil then tower.reentry = false end + return tower +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and +-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean. +function BattleTower.saveFileFlag(save, mask) + local tower = BattleTower.state(save) + return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0 +end + +function BattleTower.setSaveFileFlag(save, mask) + local tower = BattleTower.state(save) + if math.floor(tower.saveFileFlags / mask) % 2 == 0 then + tower.saveFileFlags = tower.saveFileFlags + mask + end + return tower.saveFileFlags +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904 +function BattleTower.resetTrainers(save) + local tower = BattleTower.state(save) + tower.trainers = {} + tower.streak = 0 + return tower +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022 +function BattleTower.setChallengeState(save, state) + local tower = BattleTower.state(save) + tower.challenge = counter(state) + return tower.challenge +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211 +function BattleTower.partyCountOk(party) + return #party == BattleTower.PARTY_LENGTH +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277 +-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value +-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first. +local function valuesUnique(party, get) + local count = #party + for i = 1, count - 1 do + local mon = party[i] + if not Breeding.isEgg(mon) then + local value = get(mon) + if value ~= nil and value ~= 0 then + for j = i + 1, count do + local other = party[j] + if not Breeding.isEgg(other) and get(other) == value then + return false + end + end + end + end + end + return true +end + +local function speciesOf(mon) return mon and mon.species or nil end +local function itemOf(mon) return mon and mon.item or nil end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216 +function BattleTower.speciesUnique(party) + return valuesUnique(party, speciesOf) +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282 +function BattleTower.itemsUnique(party) + return valuesUnique(party, itemOf) +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299 +function BattleTower.partyHasEgg(party) + for _, mon in ipairs(party) do + if Breeding.isEgg(mon) then return true end + end + return false +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every +-- check runs, the first failure prints the header first, and a tail line +-- follows any failure at all. Returns the text labels in printing order. +function BattleTower.checkRules(party) + party = party or {} + local failed = { + not BattleTower.partyCountOk(party), + not BattleTower.speciesUnique(party), + not BattleTower.itemsUnique(party), + BattleTower.partyHasEgg(party), + } + local lines, any = {}, false + for index = 1, #BattleTower.RULE_FAIL_TEXTS do + if failed[index] then + if not any then + any = true + lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT + end + lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index] + end + end + if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end + return lines, any +end + +-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what +-- opens rooms above L40, and the last row is always CANCEL. +function BattleTower.levelGroupCount(save) + if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end + return BattleTower.PRE_HOF_LEVEL_GROUPS +end + +function BattleTower.levelGroupRows(save) + local rows = {} + for group = 1, BattleTower.levelGroupCount(save) do + rows[group] = { group = group, level = group * 10 } + end + return rows +end + +-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the +-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47). +function BattleTower.roomOf(group) + return math.floor((math.max(1, counter(group)) - 1) / 2) +end + +-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck +function BattleTower.levelCheck(party, group) + local cap = counter(group) * 10 + for _, mon in ipairs(party or {}) do + if (tonumber(mon.level) or 0) > cap then return true end + end + return false +end + +-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below +-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49. +function BattleTower.ubersCheck(party, group) + if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end + for _, mon in ipairs(party or {}) do + if BattleTower.UBERS[mon.species] + and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then + return mon.species + end + end + return nil +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a +-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the +-- bottom of the range and rerolls LUCKY_PUNCH. +function BattleTower.rollReward(order, random) + if type(order) ~= "table" then return nil end + local low, high + for index, name in pairs(order) do + if name == BattleTower.MIN_REWARD then low = index end + if name == BattleTower.MAX_REWARD then high = index end + end + if not (low and high) then return nil end + local span = high - low + 1 + local mask = 1 + while mask < span do mask = mask * 2 end + for _ = 1, 64 do + local roll = random(mask) % mask + if roll >= span then roll = roll - span end + local item = order[low + roll] + if item ~= BattleTower.SKIPPED_REWARD then return item end + end + return order[low] +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of +-- the reward only fit when the ITEM pocket has a free slot, or already holds +-- the reward with room for five more. Otherwise the desk hands over a POTION, +-- which the script reads as "your PACK is stuffed full". +function BattleTower.rewardFits(slots, capacity, held) + if counter(slots) < counter(capacity) then return true end + if held == nil then return false end + return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1 +end + +-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/ +-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block. + +-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver. +function BattleTower.roster(data) + local trainers = data and (data.gen2Trainers or data.trainers) + local roster = trainers and trainers.battleTower + if type(roster) ~= "table" then return nil end + if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then + return nil + end + return roster +end + +-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three +-- species and the one before that's, which the draw refuses to repeat. +function BattleTower.prevTeams(save) + local tower = BattleTower.state(save) + local teams = tower.prevTeams + if type(teams) ~= "table" then + teams = {} + tower.prevTeams = teams + end + if type(teams.prev) ~= "table" then teams.prev = {} end + if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end + return teams +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the +-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes +-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10. +function BattleTower.opponentGroup(save, roster) + local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP + local group = counter(BattleTower.state(save).levelGroup) + if group < 1 then return 1 end + if group > groups then return groups end + return group +end + +-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw +-- from the survivors is the same distribution and cannot spin. An empty +-- survivor set is where the cart's own loop would hang, so it draws unfiltered. +local function drawFiltered(count, random, accept) + local pool = {} + for index = 1, count do + if accept(index) then pool[#pool + 1] = index end + end + if #pool == 0 then return random(count) end + return pool[random(#pool)] +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll +-- is refused while it names anybody already in sBTTrainers, and the winner is +-- written into the slot sNrOfBeatenBattleTowerTrainers points at. +function BattleTower.chooseTrainer(save, roster, random) + local tower = BattleTower.state(save) + -- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with + -- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows. + local ceiling = counter(roster.sampleTrainers) + if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end + local seen = {} + for slot = 1, BattleTower.STREAK_LENGTH do + local held = tonumber(tower.trainers[slot]) + if held then seen[held] = true end + end + local index = drawFiltered(ceiling, random, function(row) + return not seen[row - 1] + end) + tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1) + + 1] = index - 1 + return roster.trainers[index] +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three +-- draws out of the chosen level group, each refusing a species this team +-- already holds, an ITEM this team already holds, and any species from the +-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set +-- to $ff first (:7-17), which is why an unfilled slot collides with nothing. +function BattleTower.chooseTeam(save, roster, group, random) + local rows = roster.groups[group] or {} + local teams = BattleTower.prevTeams(save) + local picked, species, items = {}, {}, {} + for _ = 1, BattleTower.PARTY_LENGTH do + local index = drawFiltered(#rows, random, function(row) + local mon = rows[row] + if not mon then return false end + if species[mon.species] then return false end + if mon.item ~= nil and items[mon.item] then return false end + for _, seen in ipairs(teams.prev) do + if seen == mon.species then return false end + end + for _, seen in ipairs(teams.prevPrev) do + if seen == mon.species then return false end + end + return true + end) + local mon = rows[index] + if not mon then break end + picked[#picked + 1] = mon + species[mon.species] = true + if mon.item ~= nil then items[mon.item] = true end + end + -- :195-206, after all three: this team becomes sBTMonPrevTrainer and the + -- one it displaces becomes sBTMonPrevPrevTrainer. + local prev = {} + for slot, mon in ipairs(picked) do prev[slot] = mon.species end + teams.prevPrev = teams.prev + teams.prev = prev + return picked +end + +-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The +-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved -- +-- only the sBTTrainers slot and the two previous teams this walk writes are. +function BattleTower.drawOpponent(data, save, random) + local roster = BattleTower.roster(data) + if not roster then return nil end + local group = BattleTower.opponentGroup(save, roster) + local trainer = BattleTower.chooseTrainer(save, roster, random) + if not trainer then return nil end + local rows = BattleTower.chooseTeam(save, roster, group, random) + return { + index = trainer.index, + name = trainer.name, + class = trainer.class, + classId = trainer.classId, + sprite = roster.classSprites and roster.classSprites[trainer.classId], + group = group, + rows = rows, + } +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570 +-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing +-- ReadBTTrainerParty does: the challenge is now in progress and the streak +-- counter steps BEFORE the battle, which is why beating the seventh opponent +-- leaves the counter on BATTLETOWER_STREAK_LENGTH. +function BattleTower.beginBattle(save) + local tower = BattleTower.state(save) + tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS + tower.streak = counter(tower.streak) + 1 + return tower.streak +end + +-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the +-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored +-- stats go on top of Mon.new's, which agree with them anyway. +-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one +-- with GetPokemonName, so the rows carry none. +function BattleTower.battleParty(data, rows) + local party = {} + for _, row in ipairs(rows or {}) do + local moves = nil + if row.moves and #row.moves > 0 then + moves = {} + for slot, id in ipairs(row.moves) do + local def = data and data.moves and data.moves[id] + local max = (def and def.pp) or 0 + moves[slot] = { + id = id, + pp = (row.pp and row.pp[slot]) or max, + maxPp = max, + } + end + end + local dvs = row.dvs or {} + local statExp = row.statExp or {} + local mon = Mon.new(data, row.species, row.level, { + moves = moves, + item = row.item, + dvs = { attack = dvs.attack, defense = dvs.defense, + speed = dvs.speed, special = dvs.special }, + statExp = { hp = statExp.hp, attack = statExp.attack, + defense = statExp.defense, speed = statExp.speed, + special = statExp.special }, + happiness = row.happiness, + }) + if mon then + if row.stats then + mon.stats = { + hp = row.stats.hp, attack = row.stats.attack, + defense = row.stats.defense, speed = row.stats.speed, + specialAttack = row.stats.specialAttack, + specialDefense = row.stats.specialDefense, + } + end + mon.maxHp = row.maxHp or mon.maxHp + mon.hp = row.hp or mon.maxHp + mon.experience = row.experience or mon.experience + party[#party + 1] = mon + end + end + return party +end + +return BattleTower diff --git a/src/core/gen2/Breeding.lua b/src/core/gen2/Breeding.lua index 25a82de5..57db6832 100644 --- a/src/core/gen2/Breeding.lua +++ b/src/core/gen2/Breeding.lua @@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which) rebuilt.status = nil for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel + -- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA + -- included -- engine/pokemon/move_mon.asm:805. + rebuilt.caughtTime = mon.caughtTime + rebuilt.caughtLocation = mon.caughtLocation + rebuilt.caughtByGender = mon.caughtByGender rebuilt.ot, rebuilt.otId = mon.ot, mon.otId -- CalcExpAtLevel, which is the experience loss. rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel) @@ -1077,7 +1082,9 @@ end -- Returns the new record plus a table of side effects the caller owes: -- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the -- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species. -function Breeding.hatch(data, save, index, nickname) +-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and +-- playerGender -- engine/pokemon/breeding.asm:228. +function Breeding.hatch(data, save, index, nickname, where) local party = (save and save.party) or {} local egg = party[index] if not Breeding.isEgg(egg) then return nil end @@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname) hatched.ot = egg.ot or (save.player and save.player.name) hatched.otId = egg.otId or (save.player and save.player.id) hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL + -- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the + -- shared setter -- engine/pokemon/caught_data.asm:235-246. + if Mon.hasCaughtData(save and save.version) then + where = where or {} + Mon.setCaughtData(hatched, { + level = Mon.CAUGHT_EGG_LEVEL, + timeOfDay = where.timeOfDay, + landmark = where.landmark, + playerGender = where.playerGender + or (save.player and save.player.gender), + }) + end party[index] = hatched Breeding.markPokedex(save, egg.species) diff --git a/src/core/gen2/Evolution.lua b/src/core/gen2/Evolution.lua index d295debb..fe18a59d 100644 --- a/src/core/gen2/Evolution.lua +++ b/src/core/gen2/Evolution.lua @@ -357,6 +357,7 @@ Evolution.MON_FIELDS = { experience = true, dvs = true, stats = true, hp = true, maxHp = true, types = true, moves = true, item = true, status = true, happiness = true, caughtLevel = true, shiny = true, gender = true, + caughtTime = true, caughtLocation = true, caughtByGender = true, } -- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it @@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry) evolved.experience = mon.experience evolved.status = mon.status evolved.caughtLevel = mon.caughtLevel + -- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back + -- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293. + evolved.caughtTime = mon.caughtTime + evolved.caughtLocation = mon.caughtLocation + evolved.caughtByGender = mon.caughtByGender -- Anything a future field adds to a party record (mail, pokerus) rides along -- rather than being silently dropped. Only fields Mon.new does NOT own may -- be carried: copying `nickname` back would undo diff --git a/src/core/gen2/Happiness.lua b/src/core/gen2/Happiness.lua index c31daedf..400a058f 100644 --- a/src/core/gen2/Happiness.lua +++ b/src/core/gen2/Happiness.lua @@ -86,8 +86,11 @@ Happiness.EVENT = { ENERGYROOT = 16, -- 10 REVIVALHERB = 17, -- 11 GROOMING = 18, -- 12 + -- Crystal only; Gold's enum stops at GROOMING + -- (pokegold constants/pokemon_data_constants.asm:205). + GAINLEVELATHOME = 19, -- 13 } -Happiness.NUM_EVENTS = 18 +Happiness.NUM_EVENTS = 19 -- data/events/happiness_changes.asm, transcribed row for row. The three -- columns are the three tiers below, in order. @@ -110,6 +113,7 @@ Happiness.CHANGES = { { -10, -10, -15 }, -- 10 Used Energy Root (bitter) { -15, -15, -20 }, -- 11 Used Revival Herb (bitter) { 3, 3, 1 }, -- 12 Grooming + { 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal) } -- Which of HappinessChanges' three columns a CURRENT value reads. The cart @@ -174,6 +178,23 @@ function Happiness.change(mon, event) return value end +-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK +-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20. +function Happiness.levelUpEvent(mon, landmark) + local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil + landmark = tonumber(landmark) + if not (caught and landmark) then return "GAINLEVEL" end + if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then + return "GAINLEVEL" + end + return "GAINLEVELATHOME" +end + +-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19. +function Happiness.levelUp(mon, landmark) + return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark)) +end + -- The same event across a party, which is how the Gym Leader award is written -- out longhand in engine/battle/core.asm InitEnemyTrainer: -- diff --git a/src/core/gen2/Roamers.lua b/src/core/gen2/Roamers.lua index 9526f5f6..f60ce4e5 100644 --- a/src/core/gen2/Roamers.lua +++ b/src/core/gen2/Roamers.lua @@ -1,5 +1,5 @@ -- The two pieces of Gen 2 world state that override where a wild mon comes --- from: the three roaming legendaries, and swarms. +-- from: the roaming legendaries, and swarms. -- -- Both live in one module because they are the same KIND of thing -- a -- persistent record that sits in front of a map's own encounter table -- and @@ -26,6 +26,7 @@ -- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a -- beast is caught or beaten -- is nil. +local GameVersion = require("src.core.GameVersion") local Mon = require("src.battle.gen2.Mon") local Runtime = require("src.mods.Runtime") @@ -37,7 +38,7 @@ local Roamers = {} -- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a -- teleport triggers) -- so a tracker mod sees every hop. -- --- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune +-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only) -- slot that roamer record, already carrying the new map -- species the beast's species id -- from the map id it left @@ -68,9 +69,31 @@ Roamers.SPECIES = { { species = "ENTEI", level = 40, map = "ROUTE_37" }, { species = "SUICUNE", level = 40, map = "ROUTE_38" }, } -Roamers.COUNT = 3 Roamers.LEVEL = 40 +-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only, +-- so the roster is read out of the cache when the cache carries one. +local function startMapFor(species) + for _, row in ipairs(Roamers.SPECIES) do + if row.species == species then return row.map end + end + return nil +end + +function Roamers.roster(encounters) + local extracted = encounters and encounters.roamMons + if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end + local rows = {} + for _, row in ipairs(extracted) do + rows[#rows + 1] = { + species = row.species, + level = row.level or Roamers.LEVEL, + map = row.map or startMapFor(row.species), + } + end + return rows +end + -- data/wild/roammon_maps.asm, entry for entry and in order. The order matters -- twice over: `.Update` picks a connection by a two-bit index into the list, so -- shuffling one row changes which route a beast walks to, and JumpRoamMon picks @@ -185,7 +208,7 @@ end -- The save record -------------------------------------------------------------------------- -- --- save.roamers is a three-slot array in the order above, written first by the +-- save.roamers is a slot array in the roster order above, written first by the -- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower -- basement script fires. Each slot: -- @@ -215,13 +238,15 @@ function Roamers.slot(save, index) end -- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene --- flag, but a re-init would hand the player three fresh beasts, so this only --- writes when there is nothing there. +-- flag, but a re-init would hand the player fresh beasts, so this only writes +-- when there is nothing there. function Roamers.init(save, opts) if type(save) ~= "table" then return nil end if save.roamers and not (opts and opts.force) then return save.roamers end + local encounters = opts and (opts.encounters + or (opts.data and opts.data.gen2Encounters)) local list = {} - for _, row in ipairs(Roamers.SPECIES) do + for _, row in ipairs(Roamers.roster(encounters)) do list[#list + 1] = { species = row.species, level = row.level, @@ -413,7 +438,23 @@ end -- -- the roaming battle is one attack long unless it is trapped. The other -- two lists are the same routine's 50% and 10% gates and live here so the -- battle engine has one place to read them from. -Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true } +-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the +-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable. +Roamers.ALWAYS_FLEE_BY_ENGINE = { + gs = { RAIKOU = true, ENTEI = true, SUICUNE = true }, + crystal = { RAIKOU = true, ENTEI = true }, +} + +function Roamers.alwaysFleeMons(versionId) + return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)] + or Roamers.ALWAYS_FLEE_BY_ENGINE.gs +end + +-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name. +Roamers.ALWAYS_FLEE = setmetatable({}, { + __index = function(_, species) return Roamers.alwaysFleeMons()[species] end, +}) + Roamers.OFTEN_FLEE = { CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true, QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true, @@ -433,29 +474,79 @@ local Swarm = {} Roamers.Swarm = Swarm -- The state, on the save: --- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id +-- save.swarmMaps the stored map pairs, keyed by swarm kind +-- save.swarmMap the Gold single pair, kept as the legacy alias -- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F -- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2) -- save.dailyResetDay the day wDailyResetTimer was last restarted -- -- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special --- already write the first three; this module is where they are READ and where --- they expire. +-- already write the flags and the alias; this module is where they are READ +-- and where they expire. -- constants/script_constants.asm, ActivateFishingSwarm setval arguments. Swarm.FISH_NONE = 0 Swarm.FISH_QWILFISH = 1 Swarm.FISH_REMORAID = 2 +-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal +-- puts in front of a `swarm` map, choosing between its two stored pairs. +Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" } +Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" } +Swarm.DEFAULT_KIND = "DUNSPARCE" + +local KIND_NAMES = { DUNSPARCE = true, YANMA = true } + +local function kindName(kind) + if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end + if KIND_NAMES[kind] then return kind end + return Swarm.DEFAULT_KIND +end + +-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save +-- (and every save written before this record was keyed) folds onto that key. +local function goldShape(maps) + return maps.YANMA == nil +end + +function Swarm.maps(save) + if type(save) ~= "table" then return nil end + local maps = save.swarmMaps + if type(maps) ~= "table" then + maps = {} + save.swarmMaps = maps + end + if save.swarmMap ~= nil and goldShape(maps) then + maps[Swarm.DEFAULT_KIND] = save.swarmMap + end + return maps +end + +local function anyMap(save) + if type(save) ~= "table" then return false end + if save.swarmMap ~= nil then return true end + local maps = save.swarmMaps + if type(maps) ~= "table" then return false end + for _, name in ipairs(Swarm.KIND_ORDER) do + if maps[name] ~= nil then return true end + end + return false +end + -- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command -- writes the map pair AND the daily flag. A port that stored only the map -- would leave the Dunsparce call live for the rest of the game, because -- CheckSwarmFlag answers off the flag and clears the pair itself. -function Swarm.set(save, mapId) +-- +-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead. +function Swarm.set(save, mapId, kind) if type(save) ~= "table" then return false end save.dailyFlags = save.dailyFlags or {} save.dailyFlags.swarm = true - save.swarmMap = mapId + local name = kindName(kind) + local maps = Swarm.maps(save) + maps[name] = mapId + if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end return true end @@ -475,9 +566,22 @@ function Swarm.active(save) and save.dailyFlags.swarm == true end -function Swarm.mapId(save) +function Swarm.mapId(save, kind) if not Swarm.active(save) then return nil end - return save.swarmMap + local maps = Swarm.maps(save) + return maps and maps[kindName(kind)] or nil +end + +-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and +-- falls through to Yanma, so a map both are on answers Dunsparce. +function Swarm.onMap(save, mapId) + if mapId == nil or not Swarm.active(save) then return nil end + local maps = Swarm.maps(save) + if not maps then return nil end + for _, name in ipairs(Swarm.KIND_ORDER) do + if maps[name] == mapId then return name end + end + return nil end function Swarm.fishing(save) @@ -493,6 +597,12 @@ function Swarm.check(save) if type(save) ~= "table" then return 1 end if Swarm.active(save) then return 0 end if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end + -- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole, + -- which strands both of Crystal's pairs on the one daily tick. + local maps = save.swarmMaps + if type(maps) == "table" then + for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end + end save.swarmMap = nil return 1 end @@ -518,10 +628,10 @@ end -- rather than needing its own timer. Returns true when the swarm ended on -- this call. function Swarm.timeEvents(save, day) + local hadMap = anyMap(save) local reset = Swarm.checkDailyReset(save, day) - local hadMap = save and save.swarmMap ~= nil Swarm.check(save) - return reset and hadMap and (save.swarmMap == nil) + return reset and hadMap and not anyMap(save) end -- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one, @@ -538,7 +648,7 @@ end -- lookup here falls through to the map's own list. function Swarm.entry(save, encounters, mapId, kind) if not encounters then return nil end - if Swarm.mapId(save) ~= mapId then return nil end + if not Swarm.onMap(save, mapId) then return nil end local table_ = (kind == "water") and encounters.swarmWater or encounters.swarmGrass return table_ and table_[mapId] or nil diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index edc7b862..b9faa345 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -133,10 +133,33 @@ local function fs() return love.filesystem end --- The blank-name fallback is the first PlayerNameArray row, which differs --- per edition -- data/player_names.asm:12-23. -function Save.defaultPlayerName(version) - return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD" +-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and +-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35. +Save.DEFAULT_PLAYER_NAMES = { + gold = "GOLD", + silver = "SILVER", + crystal = "CHRIS", +} + +-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to +-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781). +Save.DEFAULT_PLAYER_NAMES_FEMALE = { + crystal = "KRIS", +} + +-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177). +function Save.isFemale(save) + local player = type(save) == "table" and save.player + return (player and player.gender) == "female" +end + +function Save.defaultPlayerName(version, gender) + version = version or GameVersion.get() + if gender == "female" then + local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version] + if female then return female end + end + return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD" end -- A fresh Gen 2 save. `opts` carries what the intro collected: player name, @@ -148,10 +171,13 @@ function Save.newGame(opts) version = GameVersion.get(), generation = 2, player = { - name = opts.playerName or Save.defaultPlayerName(), + name = opts.playerName + or Save.defaultPlayerName(nil, opts.gender or "male"), -- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd -- (engine/menus/intro_menu.asm:41-49). id = opts.trainerId or rand(0, 65535), + -- InitCrystalData zeroes wPlayerGender before InitGender is even offered + -- (engine/menus/init_gender.asm:1-6). gender = opts.gender or "male", money = 3000, coins = 0, @@ -379,6 +405,49 @@ local function normalizePokerus(mons) end end +-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte. +Save.GS_BALL_STATES = { have = true, given = true, used = true } + +local function counter(value) + return math.max(0, math.floor(tonumber(value) or 0)) +end + +-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the +-- way Mail.state creates sPartyMail, so a Crystal handler can index freely. +function Save.crystalState(save) + local crystal = save.crystal or {} + save.crystal = crystal + if crystal.celebiCaught == nil then crystal.celebiCaught = false end + crystal.beasts = crystal.beasts or {} + -- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance. + local buena = crystal.buenaPassword or {} + crystal.buenaPassword = buena + buena.prizesToday = counter(buena.prizesToday) + buena.streak = counter(buena.streak) + -- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor. + crystal.moveTutor = crystal.moveTutor or {} + if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end + -- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns. + crystal.unownWords = crystal.unownWords or {} + return crystal +end + +-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil +-- until one is won, which is sBattleTowerReward's zero byte (:160). +function Save.battleTowerState(save) + local tower = save.battleTower or {} + save.battleTower = tower + -- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers. + tower.streak = counter(tower.streak) + tower.best = counter(tower.best) + -- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower. + tower.challenge = counter(tower.challenge) + -- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers. + tower.prevTeams = tower.prevTeams or {} + if tower.inChallenge == nil then tower.inChallenge = false end + return tower +end + -- Fill in anything a save (or an older save) is missing, so callers can index -- freely. Runs on both newGame and load. function Save.normalize(save) @@ -390,7 +459,11 @@ function Save.normalize(save) end save.generation = 2 save.player = save.player or {} - save.player.name = save.player.name or Save.defaultPlayerName(save.version) + -- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never + -- writes it -- constants/ram_constants.asm:177. + save.player.gender = save.player.gender or "male" + save.player.name = save.player.name + or Save.defaultPlayerName(save.version, save.player.gender) save.player.id = save.player.id or rand(0, 65535) save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY)) save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS)) @@ -462,6 +535,12 @@ function Save.normalize(save) save.playTime = save.playTime or { hours = 0, minutes = 0, seconds = 0, frames = 0 } save.rtc = save.rtc or {} + -- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a + -- Gold or Silver file never grows either key. + if GameVersion.engine(save.version) == "crystal" then + Save.crystalState(save) + Save.battleTowerState(save) + end -- HallOfFame.record fills in the count and the roster list, and trims a -- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the -- party gets below, for the same reason. diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 04ed4403..07e2b849 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version) end -- Shallow listing so we can see if the extract tree exists at all. - local roots = { "yellow", "blue", "gold", "silver", "assets", + local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets", "yellow/assets/generated", "yellow/assets/generated/sprites", "blue/assets/generated/sprites", "gold/assets/generated", "gold/assets/generated/sprites", "gold/data/generated", "silver/assets/generated", - "silver/data/generated" } + "silver/data/generated", "crystal/assets/generated", + "crystal/data/generated" } for _, dir in ipairs(roots) do local info = filesystem.getInfo(dir) if info and info.type == "directory" and filesystem.getDirectoryItems then diff --git a/src/import/CrystalMovie.lua b/src/import/CrystalMovie.lua new file mode 100644 index 00000000..b0d943cb --- /dev/null +++ b/src/import/CrystalMovie.lua @@ -0,0 +1,396 @@ +-- Crystal's intro movie and title screen extraction (pokecrystal +-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374). +-- Called from RomExtractorGen2's extractIntro/extractTitle stages when +-- edition == "crystal"; reads through the passed-in extractor and never +-- mutates it. + +local ImageWriter = require("src.import.ImageWriter") + +local CrystalMovie = {} + +local TILE_BYTES = 16 +local SHEET_TILES = 16 + +-- vBGMap tilemaps and attrmaps are one full 32x32 map +-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes). +local MAP_BYTES = 1024 + +local function padTiles(raw, count) + local length = count * TILE_BYTES + while #raw > length do table.remove(raw) end + while #raw < length do raw[#raw + 1] = 0 end + return raw +end + +-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target` +-- starting at tile `at` (0-based). +local function overlayTiles(target, source, at, from, count) + for offset = 1, count * TILE_BYTES do + target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0 + end + return target +end + +local function blankTiles(count) + local out = {} + for index = 1, count * TILE_BYTES do out[index] = 0 end + return out +end + +-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16), +-- the same layout data/sprite_anims/oam.asm:120-136 assumes. +local function writeSheet(self, raw, count, relative) + padTiles(raw, count) + local rows = math.ceil(count / SHEET_TILES) + padTiles(raw, rows * SHEET_TILES) + self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true) + return "assets/generated/" .. relative +end + +local function readMap(self, label) + local bytes = self:decompressLz3Symbol(label) + while #bytes > MAP_BYTES do table.remove(bytes) end + while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end + return bytes +end + +-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies +-- `16 palettes` over wBGPals1, which wOBPals1 follows). +local function readPalettes(self, label) + local symbol = self:symbol(label) + local flat = self:colors(symbol.bank, symbol.address, 64) + local bg, obj = {}, {} + for pal = 0, 7 do + local a, b = {}, {} + for slot = 1, 4 do + a[slot] = flat[pal * 4 + slot] + b[slot] = flat[(pal + 8) * 4 + slot] + end + bg[pal + 1], obj[pal + 1] = a, b + end + return { bg = bg, obj = obj } +end + +local function readColors(self, label, count) + local symbol = self:symbol(label) + return self:colors(symbol.bank, symbol.address, count) +end + +function CrystalMovie.extractIntro(self) + self:beginStage("Intro movie") + local out = { + generation = 2, + layout = "crystal", + source = "ROM:CrystalIntro (engine/movie/intro.asm)", + } + + local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128) + local grassSym = self:symbol("IntroGrass4GFX") + local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES) + + local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png") + local pulsePath = writeSheet(self, + self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png") + local backgroundPath = writeSheet(self, + self:decompressLz3Symbol("IntroBackgroundGFX"), 128, + "intro/background_tiles.png") + local runPath = writeSheet(self, + self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192, + "intro/suicune_run_sprites.png") + local pichuPath = writeSheet(self, + self:decompressLz3Symbol("IntroPichuWooperGFX"), 128, + "intro/pichu_wooper_sprites.png") + self:tick("Intro movie", 1, 4) + + -- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0, + -- i.e. BG id $80 (engine/movie/intro.asm:744-753). + local jump = blankTiles(256) + overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128) + overlayTiles(jump, grass4, 0x80, 0, 1) + local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png") + + -- The same grass tile is the whole of the SUICUNE_AWAY object's sheet + -- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753). + local unownBack = blankTiles(144) + overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48) + overlayTiles(unownBack, grass4, 0x80, 0, 1) + local unownBackPath = writeSheet(self, unownBack, 144, + "intro/unown_back_sprites.png") + + -- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0 + -- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828). + local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255) + local close = blankTiles(256) + for id = 0, 255 do + local from = (id + 128) % 256 + if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end + end + local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png") + + -- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and + -- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901). + local back = blankTiles(256) + overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128) + overlayTiles(back, unowns, 128, 0, 128) + overlayTiles(back, grass4, 0xff, 0, 1) + local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png") + + local crystalUnownsPath = writeSheet(self, + self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32, + "intro/crystal_unowns_tiles.png") + + -- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3 + -- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4. + local grassStrip = blankTiles(16) + for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX", + "IntroGrass3GFX" }) do + local sym = self:symbol(label) + overlayTiles(grassStrip, + self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4, + 0, 4) + end + out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png") + self:tick("Intro movie", 2, 4) + + local unownPals = readPalettes(self, "IntroUnownsPalette") + local backgroundPals = readPalettes(self, "IntroBackgroundPalette") + local suicunePals = readPalettes(self, "IntroSuicunePalette") + local closePals = readPalettes(self, "IntroSuicuneClosePalette") + local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette") + + local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes) + return { + tiles = tiles, + sprites = sprites, + tilemap = readMap(self, tilemapLabel), + attrmap = readMap(self, attrmapLabel), + palettes = palettes, + } + end + + out.acts = { + unownA = act(unownsPath, pulsePath, + "IntroUnownATilemap", "IntroUnownAAttrmap", unownPals), + unownHI = act(unownsPath, pulsePath, + "IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals), + unowns = act(unownsPath, pulsePath, + "IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals), + background = act(backgroundPath, runPath, + "IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals), + suicuneJump = act(jumpPath, unownBackPath, + "IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals), + suicuneClose = act(closePath, nil, + "IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals), + suicuneBack = act(backPath, unownBackPath, + "IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals), + crystalUnowns = act(crystalUnownsPath, nil, + "IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap", + crystalUnownsPals), + } + -- IntroScene7/10 load pichu_wooper into VRAM bank 1 + -- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet. + out.acts.background.sprites1 = pichuPath + self:tick("Intro movie", 3, 4) + + out.fades = { + toWhite = {}, + unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4), + unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4), + wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16), + wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16), + } + -- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each + -- (engine/movie/intro.asm:1155-1189). + local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32) + for row = 0, 7 do + local pal = {} + for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end + out.fades.toWhite[row + 1] = pal + end + + self:write("intro", out) + self:tick("Intro movie", 4, 4) + return out +end + +-------------------------------------------------------------------------- + +local function shadeOf(r) + if r > 0.9 then return 0 end + if r > 0.5 then return 1 end + if r > 0.2 then return 2 end + return 3 +end + +local function tilesFrom2bpp(raw) + local tiles = {} + for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do + local one = {} + for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end + tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true) + end + return tiles +end + +local function blitTile(target, tile, tx, ty) + if not tile then return end + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = tile:getPixel(x, y) + if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end + end + end +end + +local function colorize(image, palFor) + local w, h = image:getDimensions() + local out = ImageWriter.blank(w, h, 0, 0, 0, 0) + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, _, _, a = image:getPixel(x, y) + if a ~= 0 then + local pal = palFor(x, y) + local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 } + out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1) + end + end + end + return out +end + +function CrystalMovie.extractTitle(self) + self:beginStage("Title screen") + + -- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and + -- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94). + local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX")) + -- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f + -- (engine/movie/title.asm:24-27). + local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX")) + local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX")) + local pals = readPalettes(self, "TitleScreenPalettes") + self:tick("Title screen", 1, 5) + + -- Attribute regions from _TitleScreen's ByteFills + -- (engine/movie/title.asm:39-85): logo gradient rows, the version strip, + -- pal 7 on the window copyright line, pal 0 everywhere else. + local function bgPalAt(col, row) + if row == 9 and col >= 5 and col <= 15 then return 1 end + if row >= 3 and row <= 4 then return 2 end + if row == 5 then return 3 end + if row == 6 then return 4 end + if row == 7 then return 5 end + if row >= 8 and row <= 9 then return 6 end + if row == 17 then return 7 end + return 0 + end + + local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0) + -- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80 + -- (engine/movie/title.asm:107-112,275-302). + for row = 0, 6 do + for col = 0, 19 do + blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8) + end + end + -- Copyright line: 13 tiles from d=$c on the window's row 0, shown at + -- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122). + for index = 0, 12 do + blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8) + end + + local colored = colorize(shade, function(x, y) + return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1] + end) + self:save(colored, "title/crystal_screen.png") + self:save(shade, "title/crystal_screen_gray.png") + + local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0) + ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56) + self:save(logo, "title/crystal_logo.png") + local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0) + ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8) + self:save(wordmark, "title/crystal_wordmark.png") + self:tick("Title screen", 2, 5) + + -- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16; + -- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273). + local suicunePaths, suicuneGrayPaths = {}, {} + for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do + local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0) + for row = 0, 5 do + for col = 0, 7 do + blitTile(frame, suicuneTiles[base + row * 16 + col + 1], + col * 8, row * 8) + end + end + local tinted = colorize(frame, function() return pals.bg[1] end) + local rel = ("title/crystal_suicune_%d.png"):format(index) + self:save(tinted, rel) + suicunePaths[index] = "assets/generated/" .. rel + local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index) + self:save(frame, grayRel) + suicuneGrayPaths[index] = "assets/generated/" .. grayRel + if index == 1 then self:save(tinted, "title/crystal_suicune.png") end + end + self:tick("Title screen", 3, 5) + + -- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive + -- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338). + local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0) + for strip = 0, 4 do + for slot = 0, 5 do + local tile = strip * 12 + slot * 2 + blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16) + blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8) + end + end + local gemTinted = colorize(gem, function() return pals.obj[1] end) + self:save(gemTinted, "title/crystal_gem.png") + self:save(gem, "title/crystal_gem_gray.png") + self:tick("Title screen", 4, 5) + + local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end + + local data = { + generation = 2, + layout = "crystal_title", + source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX" + .. " + TitleScreenPalettes", + screen = "assets/generated/title/crystal_screen.png", + screenGray = "assets/generated/title/crystal_screen_gray.png", + image = "assets/generated/title/crystal_logo.png", + wordmark = "assets/generated/title/crystal_wordmark.png", + suicune = "assets/generated/title/crystal_suicune.png", + suicuneFrames = suicunePaths, + suicuneFramesGray = suicuneGrayPaths, + -- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels. + suicuneX = 48, + suicuneY = 96, + -- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243). + suicuneEvery = 8, + gem = "assets/generated/title/crystal_gem.png", + gemGray = "assets/generated/title/crystal_gem_gray.png", + -- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen + -- (56, 6) (engine/movie/title.asm:306-311,340-362). + gemX = 56, + gemY = 6, + gemFromY = -50, + gemStep = 2, + -- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line + -- signage over the logo's 80 lines; the copyright window sits at + -- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111). + entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 }, + entranceSfx = "Sfx_TitleScreenEntrance", + -- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136). + timeoutFrames = 73 * 60 + 36, + sky = unit(pals.bg[1][1]), + below = unit(pals.bg[1][1]), + palettes = { bg = pals.bg, obj = pals.obj }, + } + self:write("title", data) + self:tick("Title screen", 5, 5) + return data +end + +return CrystalMovie diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 2f20a15d..f88c6b11 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -405,6 +405,7 @@ end local CART_COLOR = { red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, gold = PAL.railAmber, silver = PAL.railSilver, + crystal = PAL.railCrystal, } local function cartColor(version) return CART_COLOR[version] or PAL.green @@ -1062,6 +1063,8 @@ local GAME_TABS = { label = "Gold" }, { id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver, label = "Silver" }, + { id = "crystal", key = "tab-crystal", letter = "C", + color = PAL.railCrystal, label = "Crystal" }, } local HEADER_TABS = { @@ -3555,24 +3558,37 @@ end -- also what a controller reaches after the tab row. local function buildGameModal(imp, m) local pad = math.floor(18 * m.s) - local gap = math.floor(8 * m.s) - local w = math.floor(360 * m.s) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + #GAME_TABS * (m.btnH + gap) + m.btnH + pad - local px, py, pw = modalPanel(m, w, h) + local headH = Kit.textHeight("button") + math.floor(12 * m.s) + local avail = m.H - 2 * m.pad + local cols, gap, btnH = 1, math.floor(8 * m.s), m.btnH + local function rows() return math.ceil(#GAME_TABS / cols) + 1 end + local function total() return 2 * pad + headH + rows() * btnH + + (rows() - 1) * gap end + if total() > avail then cols = 2 end + if total() > avail then gap = math.max(2, math.floor(3 * m.s)) end + if total() > avail then + btnH = math.max(Kit.tapMin(), + btnH - math.ceil((total() - avail) / rows())) + end + local nrows = rows() - 1 + local w = math.floor((cols > 1 and 440 or 360) * m.s) + local px, py, pw = modalPanel(m, w, total()) local cy = py + pad Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + cy = cy + headH local chrome = headerChrome(imp) - for _, g in ipairs(GAME_TABS) do - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id, + local colW = math.floor((pw - 2 * pad - (cols - 1) * gap) / cols) + for i, g in ipairs(GAME_TABS) do + local bx = px + pad + ((i - 1) % cols) * (colW + gap) + local by = cy + math.floor((i - 1) / cols) * (btnH + gap) + btn(imp, bx, by, colW, btnH, "gamepop-" .. g.id, Strings(g.label), { face = "tab", font = "small", letter = g.letter, color = g.color, active = imp.tab == g.id, action = chrome.tab[g.id] }) - cy = cy + m.btnH + gap end - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close", + cy = cy + nrows * (btnH + gap) + btn(imp, px + pad, cy, pw - 2 * pad, btnH, "gamepop-close", Strings("Close"), { font = "small", action = function() imp._gamePopup = nil end }) end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 1ad47af4..0de65b5d 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -7,6 +7,7 @@ local bit = require("bit") local GameVersion = require("src.core.GameVersion") local ImageWriter = require("src.import.ImageWriter") local LuaWriter = require("src.import.LuaWriter") +local MonAnim = require("src.render.MonAnim") local Rom = require("src.import.Rom") -- engine/gfx/load_pics.asm FixPicBank. `dba_pic` does NOT store the real @@ -19,6 +20,9 @@ local FIX_PIC_BANK = { [0x14] = 0x20, -- BANK("Pics 13") [0x1f] = 0x2e, -- BANK("Pics 14") } +-- ../pokecrystal/engine/gfx/load_pics.asm:250 EXPORT DEF PICS_FIX EQU $36, and +-- ../pokecrystal/macros/data.asm:93-96 stores BANK(pic) - PICS_FIX flat. +local PICS_FIX = 0x36 local RomExtractorGen2 = {} RomExtractorGen2.__index = RomExtractorGen2 @@ -48,8 +52,10 @@ local ENV_POINTER_COUNT = 8 -- NUM_ENVIRONMENTS + 1 (row 0 is unused) local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS -- gfx/tileset_palette_maps.asm lives in "bank2" (main.asm) alongside -- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit --- pointer, so the bank has to come from here. +-- pointer, so the bank has to come from here. Crystal moved the include to +-- "bank13" (../pokecrystal/main.asm:192-195). local PAL_MAP_BANK = 0x02 +local PAL_MAP_BANK_CRYSTAL = 0x13 -- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b -- carries no manifest symbol to resolve it through. local BALL_ICON_GFX = { 0x0b, 0x41a4 } @@ -57,6 +63,10 @@ local BALL_ICON_GFX = { 0x0b, 0x41a4 } -- tiles per byte: low nibble first tile, high nibble second (`dn` in the -- tilepal macro). The high bit of each nibble is the VRAM bank, not colour. local TILESET_TILE_COUNT = 96 +-- ../pokecrystal/home/map.asm:1368 copies a second $60 tiles to VRAM bank 1, +-- and engine/tilesets/map_palettes.asm:40 carries that bank in the id's bit 7. +local TILESET_VRAM_TILES = 256 +local CRYSTAL_PAL_MAP_BYTES = 112 -- Every Gen 2 back pic is 6x6 tiles (48x48); only front pics vary in size. local BACK_PIC_TILES = 6 @@ -99,10 +109,27 @@ local MENU_HEADER_LENGTH = 8 -- its own terminator inside its 11 bytes. local NPCTRADE_STRUCT_LENGTH = 32 local MON_NAME_LENGTH, NAME_LENGTH = 11, 11 +-- ../pokecrystal/constants/npc_trade_constants.asm:23 adds NPC_TRADE_FOREST, +-- and :48 adds TRADE_DIALOGSET_GIRL, so PrintTradeText's row stride grows too +-- (../pokecrystal/engine/events/npc_trade.asm:389-395 `ld bc, 2 * 4`). local NUM_NPC_TRADES = 6 +local NUM_NPC_TRADES_CRYSTAL = 7 -- constants/script_constants.asm NUM_BUG_CONTESTANTS, "not counting the -- player", which data/events/bug_contest_flags.asm asserts its length against. local NUM_BUG_CONTESTANTS = 10 +-- ../pokecrystal/constants/script_constants.asm:323-324 NUM_UNOWN_WALLS and +-- UNOWN_WALL_MENU_HEADER_SIZE. +local NUM_UNOWN_WALLS = 4 +local UNOWN_WALL_HEADER_SIZE = 5 +-- constants/item_constants.asm NUM_TM_HM: 50 TMs + 7 HMs in both trees. +local NUM_TM_HM = 57 +-- ../pokecrystal/constants/pokemon_data_constants.asm:113-115. +local PARTYMON_STRUCT_LENGTH = 48 +local NICKNAMED_MON_STRUCT_LENGTH = PARTYMON_STRUCT_LENGTH + MON_NAME_LENGTH +-- ../pokecrystal/data/battle_tower/parties.asm:2, and +-- ../pokecrystal/constants/battle_tower_constants.asm:4. +local BT_LEVEL_GROUPS = 10 +local BT_PARTY_LENGTH = 3 -- constants/phone_constants.asm PHONE_CONTACT_SIZE and SPECIALCALL_SIZE. local PHONE_CONTACT_SIZE = 12 local SPECIALCALL_SIZE = 6 @@ -175,19 +202,35 @@ local function copy(value) end function RomExtractorGen2.new(romData, manifest, progress) + -- _GOLD / _SILVER: the labels are shared, the data behind a handful of + -- them is not (gfx/misc.asm:9-20 vs :46-57). + local edition = GameVersion.forSha1(manifest.romSha1) or "gold" return setmetatable({ rom = Rom.new(romData), manifest = manifest, symbols = manifest.symbols, progress = progress, stage = 0, - -- _GOLD / _SILVER: the labels are shared, the data behind a handful of - -- them is not (gfx/misc.asm:9-20 vs :46-57). - edition = GameVersion.forSha1(manifest.romSha1) == "silver" - and "silver" or "gold", + edition = edition, + -- ../pokecrystal/macros/scripts/events.asm:540 renumbers every command + -- from $52 up, so the whole table has to be resolved per edition. + opcodes = Opcodes.forEdition(edition), }, RomExtractorGen2) end +-- gfx/tileset_palette_maps.asm's bank (../pokecrystal/main.asm:192-195). +function RomExtractorGen2:palMapBank() + if self.edition == "crystal" then return PAL_MAP_BANK_CRYSTAL end + return PAL_MAP_BANK +end + +-- A `dba_pic` bank byte back to the real ROM bank: Gold's three-entry remap +-- (macros/data.asm:101-107) or Crystal's flat `+ PICS_FIX`. +function RomExtractorGen2:picBank(stored) + if self.edition == "crystal" then return stored + PICS_FIX end + return FIX_PIC_BANK[stored] or stored +end + function RomExtractorGen2:symbol(name) local location = self.symbols[name] if not location then error("required symbol is missing: " .. tostring(name)) end @@ -254,17 +297,21 @@ end -- square). pret builds these with `rgbgfx --columns`, so the 2bpp stream -- is column-major; ImageWriter.columnsToRows puts them back into a normal -- top-to-bottom PNG (same as Gen 1's interleave handling). +-- Returns the whole stream: past the base picture are the animation's extra +-- tiles, GetAnimatedEnemyFrontpic's second Get2bpp. +-- ../pokecrystal/engine/gfx/load_pics.asm:132-171 function RomExtractorGen2:writeCompressedPic(label, tiles, relative) - local pixels = self:decompressLz3Symbol(label) + local stream = self:decompressLz3Symbol(label) local size = tiles * 8 local byteLength = size * size / 4 - while #pixels < byteLength do pixels[#pixels + 1] = 0 end - while #pixels > byteLength do table.remove(pixels) end + local pixels = {} + for index = 1, byteLength do pixels[index] = stream[index] or 0 end pixels = ImageWriter.columnsToRows(pixels, tiles, tiles) -- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking, -- so matte the white backdrop like Gen 1's writeCompressedPic does. self:save(ImageWriter.matteColor0( ImageWriter.decode2bpp(pixels, size, size)), relative) + return stream end function RomExtractorGen2:extractConstants() @@ -407,6 +454,19 @@ function RomExtractorGen2:extractFont() ImageWriter.blit(extraImg, solidImg, 0, 0, 0, 0, 8, 8) -- $60 ImageWriter.blit(extraImg, solidImg, 8, 0, 8, 0, 8, 8) -- $61 end + -- Crystal splits the pair and makes the arrow 2bpp: black.1bpp at $60, + -- up_arrow.2bpp at $61 (../pokecrystal/gfx/font.asm:51,63; + -- ../pokecrystal/engine/gfx/load_font.asm:44-48,59-64). + if self.symbols["FontsExtra_SolidBlackGFX"] then + local solid = self:symbol("FontsExtra_SolidBlackGFX") + ImageWriter.blit(extraImg, inkFrom1bpp( + self.rom:bytes(solid.bank, solid.address, 8), 8, 8), 0, 0, 0, 0, 8, 8) + end + if self.symbols["FontsExtra2_UpArrowGFX"] then + local arrow = self:symbol("FontsExtra2_UpArrowGFX") + ImageWriter.blit(extraImg, inkFrom2bpp( + self.rom:bytes(arrow.bank, arrow.address, 16), 8, 8), 8, 0, 0, 0, 8, 8) + end if self.symbols["PokegearPhoneIconGFX"] then local phone = self:symbol("PokegearPhoneIconGFX") local phoneImg = inkFrom2bpp( @@ -518,13 +578,15 @@ function RomExtractorGen2:battleObjectPals() return out end --- A tileset's PalMap: 48 bytes, two tiles apiece. `tilepal` emits --- `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the low nibble is --- the even tile and the high nibble the odd one; masking to 3 bits drops the --- OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based so the value --- indexes an 8-entry Lua palette set directly. +-- A tileset's PalMap: 48 bytes on Gold and 112 on Crystal, two tiles apiece. +-- `tilepal` emits `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the +-- low nibble is the even tile and the high nibble the odd one; masking to 3 +-- bits drops the OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based +-- so the value indexes an 8-entry Lua palette set directly. function RomExtractorGen2:readPalMap(address) - local raw = self.rom:bytes(PAL_MAP_BANK, address, TILESET_TILE_COUNT / 2) + local length = TILESET_TILE_COUNT / 2 + if self.edition == "crystal" then length = CRYSTAL_PAL_MAP_BYTES end + local raw = self.rom:bytes(self:palMapBank(), address, length) local out = {} for i, byte in ipairs(raw) do out[(i - 1) * 2 + 1] = byte % 8 + 1 @@ -681,6 +743,13 @@ local ANIM_FUNCTIONS = { "ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile", "AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2", "AnimateTowerPillarTile", "FlickeringCaveEntrancePalette", + -- Crystal only: TilesetParkAnim and TilesetForestAnim stop aliasing + -- Tileset0Anim and name these instead + -- (../pokecrystal/data/tileset_anims.asm:25,38, + -- ../pokecrystal/engine/tilesets/tileset_anims.asm:159,234,277,316,354). + "AnimateFountainTile", + "ForestTreeLeftAnimation", "ForestTreeRightAnimation", + "ForestTreeLeftAnimation2", "ForestTreeRightAnimation2", } local ANIM_BANK = 0x3f @@ -727,44 +796,50 @@ function RomExtractorGen2:readTilesetAnim(address, byAddress, strips) local arg = self.rom:word(ANIM_BANK, at) local func = self.rom:word(ANIM_BANK, at + 2) local name = byAddress[func] - if not name then return nil end - local frame = { func = name } - -- A vTiles2 argument is the VRAM tile the step writes; a wTileAnimBuffer - -- one is WRAM and has no tile id. - if arg >= VTILES2 and arg < VTILES2 + 0x800 then - frame.tile = math.floor((arg - VTILES2) / 16) - end - local kind = ANIM_POINTER_KIND[name] - if kind then - local dest = self.rom:word(ANIM_BANK, arg) - if dest >= VTILES2 and dest < VTILES2 + 0x800 then - frame.tile = math.floor((dest - VTILES2) / 16) - local strip = self:animStrip(strips, - ("%s_%02x"):format(kind, frame.tile), kind, - ANIM_BANK, self.rom:word(ANIM_BANK, arg + 2)) - frame.sheet, frame.frames = strip.image, strip.frames - end - elseif name == "AnimateLavaBubbleTile1" or name == "AnimateLavaBubbleTile2" - then - -- Both take no argument: tile $5b and tile $38, one strip (:254, :279). - frame.tile = (name == "AnimateLavaBubbleTile1") and 0x5b or 0x38 - local lava = self.symbols["LavaBubbleTileFrames"] - if lava then - local strip = self:animStrip( - strips, "lava", "lava", lava[1], lava[2]) - frame.sheet, frame.frames = strip.image, strip.frames - end - elseif name == "ReadTileToAnimBuffer" then - pending = { h = 0, v = 0 } - elseif name == "ScrollTileRightLeft" then - if pending then pending.h = pending.h + 1 end - elseif name == "ScrollTileDown" then - if pending then pending.v = pending.v + 1 end - elseif name == "ScrollTileUp" then - if pending then pending.v = pending.v - 1 end - elseif name == "WriteTileFromAnimBuffer" then - if pending and frame.tile then frame.scroll = pending end + -- _AnimateTileset runs one row per frame off wTilesetAnim + -- (../pokecrystal/engine/tilesets/tileset_anims.asm:1-3), so a step with + -- no symbol costs that frame rather than the whole program. + local frame = { func = name or ("unknown_%04x"):format(func) } + if not name then pending = nil + else + -- A vTiles2 argument is the VRAM tile the step writes; a wTileAnimBuffer + -- one is WRAM and has no tile id. + if arg >= VTILES2 and arg < VTILES2 + 0x800 then + frame.tile = math.floor((arg - VTILES2) / 16) + end + local kind = ANIM_POINTER_KIND[name] + if kind then + local dest = self.rom:word(ANIM_BANK, arg) + if dest >= VTILES2 and dest < VTILES2 + 0x800 then + frame.tile = math.floor((dest - VTILES2) / 16) + local strip = self:animStrip(strips, + ("%s_%02x"):format(kind, frame.tile), kind, + ANIM_BANK, self.rom:word(ANIM_BANK, arg + 2)) + frame.sheet, frame.frames = strip.image, strip.frames + end + elseif name == "AnimateLavaBubbleTile1" + or name == "AnimateLavaBubbleTile2" then + -- Both take no argument: tile $5b and tile $38, one strip (:254, :279). + frame.tile = (name == "AnimateLavaBubbleTile1") and 0x5b or 0x38 + local lava = self.symbols["LavaBubbleTileFrames"] + if lava then + local strip = self:animStrip( + strips, "lava", "lava", lava[1], lava[2]) + frame.sheet, frame.frames = strip.image, strip.frames + end + elseif name == "ReadTileToAnimBuffer" then + pending = { h = 0, v = 0 } + elseif name == "ScrollTileRightLeft" then + if pending then pending.h = pending.h + 1 end + elseif name == "ScrollTileDown" then + if pending then pending.v = pending.v + 1 end + elseif name == "ScrollTileUp" then + if pending then pending.v = pending.v - 1 end + elseif name == "WriteTileFromAnimBuffer" then + if pending and frame.tile then frame.scroll = pending end + pending = nil + end end frames[#frames + 1] = frame if name == "DoneTileAnimation" then @@ -774,6 +849,19 @@ function RomExtractorGen2:readTilesetAnim(address, byAddress, strips) return nil end +-- ../pokecrystal/home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes. +local function crystalTilesetSheet(pixels) + local half = TILESET_TILE_COUNT * 16 + local bank1 = (TILESET_VRAM_TILES / 2) * 16 + local out = {} + for i = 1, bank1 + half do out[i] = 0 end + for i = 1, half do + out[i] = pixels[i] or 0 + out[bank1 + i] = pixels[half + i] or 0 + end + return out +end + -- Every Tilesets row is TILESET_LENGTH (15) bytes: dba GFX, dba Meta, -- dba Coll, dw Anim, dw NULL, dw PalMap (data/tilesets.asm's `tileset` -- macro). Row 0 is the unused "Tileset0" alias of TilesetJohto, and row @@ -783,7 +871,10 @@ function RomExtractorGen2:extractTilesets() self:beginStage("World tiles") local order = self.manifest.constants.tilesetOrder local headers = self:symbol("Tilesets") - local imageWidth, imageHeight = 128, 48 + local twoBank = self.edition == "crystal" + local sheetTiles = twoBank and TILESET_VRAM_TILES or TILESET_TILE_COUNT + local imageWidth = 128 + local imageHeight = sheetTiles / (imageWidth / 8) * 8 local byteLength = imageWidth * imageHeight / 4 -- A cache built from a manifest without the tileset_anims symbols simply @@ -815,6 +906,7 @@ function RomExtractorGen2:extractTilesets() local compressed = self.rom:bytes( gfxBank, gfxAddress, 0x8000 - gfxAddress) local pixels = Rom.decompressLz3(compressed) + if twoBank then pixels = crystalTilesetSheet(pixels) end while #pixels < byteLength do pixels[#pixels + 1] = 0 end while #pixels > byteLength do table.remove(pixels) end local base = constName:lower():gsub("^tileset_", "") @@ -851,9 +943,9 @@ function RomExtractorGen2:extractTilesets() collision = collision, -- Anim callbacks live in bank $3f (data/tilesets.asm). anim = self:readTilesetAnim(animAddress, animByAddress, animStrips), - palMap = { bank = PAL_MAP_BANK, address = palMapAddress }, - -- Which of the eight loaded BG palettes each of the 96 tiles draws - -- with, 1-based into palettes.bg slots (see readPalMap). + palMap = { bank = self:palMapBank(), address = palMapAddress }, + -- Which of the eight loaded BG palettes each sheet tile draws with, + -- 1-based into palettes.bg slots (see readPalMap). tilePalettes = self:readPalMap(palMapAddress), } self:tick("World tiles", index, #order) @@ -1340,6 +1432,129 @@ function RomExtractorGen2:extractMonSprites(out) return out end +-- GetMonFramesPointer reads the pointer out of BANK(FramesPointers) but the +-- blob out of BANK(KantoFrames) below this and BANK(JohtoFrames) at or above. +-- ../pokecrystal/engine/gfx/pic_animation.asm:956-1000 +local JOHTO_POKEMON = 152 + +-- A frames blob is one `dw` per frame -- the first points past the list, so +-- the gap IS the count -- then a bitmask id and one tile per set bit. +-- ../pokecrystal/engine/gfx/pic_animation.asm:487-519 +function RomExtractorGen2:readMonFrames(bank, address, bitmaskBank, bitmaskAddress, + tiles) + local first = self.rom:word(bank, address) + local count = (first - address) / 2 + if count < 1 or count > 64 or count % 1 ~= 0 then return nil end + local width = MonAnim.BITMASK_BYTES[tiles] + if not width then return nil end + local bitmasks, ids, frames = {}, {}, {} + for index = 1, count do + local pointer = self.rom:word(bank, address + (index - 1) * 2) + local raw = self.rom:byte(bank, pointer) + local slot = ids[raw] + if not slot then + slot = #bitmasks + 1 + ids[raw] = slot + bitmasks[slot] = self.rom:bytes( + bitmaskBank, bitmaskAddress + raw * width, width) + end + local list = {} + for bit = 0, tiles * tiles - 1 do + local byte = bitmasks[slot][math.floor(bit / 8) + 1] or 0 + if math.floor(byte / 2 ^ (bit % 8)) % 2 == 1 then + list[#list + 1] = self.rom:byte(bank, pointer + #list + 1) + end + end + frames[index] = { bitmask = slot, tiles = list } + end + return frames, bitmasks +end + +-- A script is (command, parameter) pairs; $ff ends it, $fe/$fd are the repeat +-- pair and everything else is a frame id with a duration. +-- ../pokecrystal/macros/scripts/pic_anims.asm:1-27 +function RomExtractorGen2:readMonAnimScript(bank, address) + local rows = {} + for index = 0, 127 do + local command = self.rom:byte(bank, address + index * 2) + if command == MonAnim.END then return rows end + rows[#rows + 1] = { command, self.rom:byte(bank, address + index * 2 + 1) } + end + return nil +end + +-- One column of whole pictures, the base picture first, matted the way +-- writeCompressedPic mattes the static pic. +function RomExtractorGen2:writeMonAnimSheet(stream, tiles, frames, bitmasks, + relative) + local size = tiles * 8 + local perTile = 16 + local available = math.floor(#stream / perTile) + local sheet = ImageWriter.blank(size, size * (#frames + 1), 0, 0, 0, 0) + local data = { tiles = tiles, frames = frames, bitmasks = bitmasks } + for index = 0, #frames do + local map = MonAnim.tileMap(data, index) + if not map then return nil end + local raw = {} + for _, id in ipairs(map) do + if id >= available then return nil end + for offset = 1, perTile do raw[#raw + 1] = stream[id * perTile + offset] end + end + local image = ImageWriter.matteColor0(ImageWriter.decode2bpp( + ImageWriter.columnsToRows(raw, tiles, tiles), size, size)) + ImageWriter.blit(sheet, image, 0, index * size) + end + self:save(sheet, relative) + return "assets/generated/" .. relative +end + +-- The four pointer tables, or nil when this ROM names none of them. +function RomExtractorGen2:monAnimTables(unown) + local prefix = unown and "Unown" or "" + local names = { anim = prefix .. "AnimationPointers", + idle = prefix .. "AnimationIdlePointers", + bitmasks = prefix .. "BitmasksPointers", + frames = prefix .. "FramesPointers" } + local out = {} + for key, name in pairs(names) do + local location = self.symbols[name] + if not location then return nil end + out[key] = { bank = location[1], address = location[2] } + end + -- KantoFrames shares FramesPointers' section and JohtoFrames shares + -- UnownFramesPointers', so both data banks come off those two symbols. + -- ../pokecrystal/main.asm:439-449 + local johto = self.symbols["UnownFramesPointers"] + if not johto then return nil end + out.kantoFrames = out.frames.bank + out.johtoFrames = unown and out.frames.bank or johto[1] + return out +end + +-- Everything one pic needs to animate, or nil when the data does not read +-- back cleanly. +function RomExtractorGen2:monAnimation(tables, index, tiles, stream, name) + if not (tables and stream and tiles) then return nil end + local frames, bitmasks = self:readMonFrames( + (index < JOHTO_POKEMON) and tables.kantoFrames or tables.johtoFrames, + self.rom:word(tables.frames.bank, tables.frames.address + (index - 1) * 2), + tables.bitmasks.bank, + self.rom:word(tables.bitmasks.bank, + tables.bitmasks.address + (index - 1) * 2), + tiles) + if not frames then return nil end + local play = self:readMonAnimScript(tables.anim.bank, + self.rom:word(tables.anim.bank, tables.anim.address + (index - 1) * 2)) + local idle = self:readMonAnimScript(tables.idle.bank, + self.rom:word(tables.idle.bank, tables.idle.address + (index - 1) * 2)) + if not (play and idle and #play > 0) then return nil end + local sheet = self:writeMonAnimSheet(stream, tiles, frames, bitmasks, + "battle/anim/" .. name .. ".png") + if not sheet then return nil end + return { tiles = tiles, sheet = sheet, count = #frames, + bitmasks = bitmasks, frames = frames, play = play, idle = idle } +end + -- BaseData rows are BASE_DATA_SIZE (32) bytes, and -- unlike Gen 1's Kanto -- reorder -- the row index already IS the dex number (data/pokemon/ -- base_stats.asm lists species in constants/pokemon_constants.asm order, @@ -1434,11 +1649,17 @@ function RomExtractorGen2:extractPokemon() -- TMHMMoves maps a TM/HM number to the move it teaches; a species' BASE_TMHM -- bitfield is indexed by that same number (see the tmhm macro), so decoding - -- it needs this table rather than a move id. - local tmhmList = {} + -- it needs this table rather than a move id. Crystal appends the three move + -- tutors as numbers 58-60 (../pokecrystal/data/moves/tmhm_moves.asm:20-25, + -- ../pokecrystal/constants/item_constants.asm:304-310), which are teachable + -- but are not TMs, so they come out as their own list. + local tmhmList, tutorList = {}, {} for i = 0, 63 do local moveId = self.rom:byte(tmhmMoves.bank, tmhmMoves.address + i) if moveId == 0 then break end + if i + 1 > NUM_TM_HM then + tutorList[i + 1 - NUM_TM_HM] = moveOrder[moveId] or moveId + end tmhmList[i + 1] = moveOrder[moveId] or moveId end @@ -1461,6 +1682,8 @@ function RomExtractorGen2:extractPokemon() end local out = { growthRates = growth, tmhmMoves = tmhmList } + if #tutorList > 0 then out.tutorMoves = tutorList end + local animTables = self:monAnimTables(false) for index, species in ipairs(speciesOrder) do if species then local row = self.rom:bytes( @@ -1472,11 +1695,17 @@ function RomExtractorGen2:extractPokemon() local asset = self.manifest.pokemonAssets[species] local tiles = row[18] % 16 -- BASE_PIC_SIZE low nibble, tiles wide/tall - local front, back + local front, back, anim if asset and asset.frontLabel then - self:writeCompressedPic(asset.frontLabel, tiles, + local stream = self:writeCompressedPic(asset.frontLabel, tiles, "battle/front/" .. asset.front .. ".png") front = asset.front + if animTables and asset.animLabel and asset.framesLabel + and asset.bitmaskLabel and asset.idleLabel then + local ok, result = pcall(self.monAnimation, self, animTables, index, + tiles, stream, asset.front) + if ok then anim = result else self:trace(species .. " anim: " .. tostring(result)) end + end end if asset and asset.backLabel then -- Back pics are ALWAYS 6x6 tiles (48x48). BASE_PIC_SIZE's low nibble @@ -1518,13 +1747,17 @@ function RomExtractorGen2:extractPokemon() -- TM/HM number n * 8 + i + 1 (the tmhm macro's layout). local tmhmRaw = { row[25], row[26], row[27], row[28], row[29], row[30], row[31], row[32] } - local tmhm = {} + local tmhm, tutorMoves = {}, {} for byteIndex, byteValue in ipairs(tmhmRaw) do for bit = 0, 7 do if math.floor(byteValue / 2 ^ bit) % 2 == 1 then local number = (byteIndex - 1) * 8 + bit + 1 local move = tmhmList[number] - if move then tmhm[#tmhm + 1] = move end + if move and number > NUM_TM_HM then + tutorMoves[#tutorMoves + 1] = move + elseif move then + tmhm[#tmhm + 1] = move + end end end end @@ -1554,11 +1787,13 @@ function RomExtractorGen2:extractPokemon() eggGroupsRaw = row[24], tmhmRaw = tmhmRaw, tmhm = tmhm, + tutorMoves = (#tutorMoves > 0) and tutorMoves or nil, evolutions = evolutions, levelMoves = levelMoves, eggMoves = eggMoves, spriteFront = front and ("assets/generated/battle/front/" .. front .. ".png") or nil, spriteBack = back and ("assets/generated/battle/back/" .. back .. ".png") or nil, + anim = anim, } end self:tick("Pokemon", index, #speciesOrder) @@ -1571,13 +1806,14 @@ function RomExtractorGen2:extractPokemon() local symbol = self:symbol("UnownPicPointers") local letters = {} local tiles = out.UNOWN.picSize or 6 + local unownTables = self:monAnimTables(true) for index = 0, 25 do local letter = string.char(string.byte("A") + index) local base = symbol.address + index * 6 local entry = {} + local frontStream local function readPic(offset, size, folder, key) - local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base + offset)] - or self.rom:byte(symbol.bank, base + offset) + local bank = self:picBank(self.rom:byte(symbol.bank, base + offset)) local address = self.rom:word(symbol.bank, base + offset + 1) local rel = ("battle/%s/unown_%s.png"):format(folder, letter:lower()) local ok, err = pcall(function() @@ -1589,11 +1825,12 @@ function RomExtractorGen2:extractPokemon() for _, byte in ipairs(nextBank) do compressed[#compressed + 1] = byte end - local pixels = Rom.decompressLz3(compressed) + local stream = Rom.decompressLz3(compressed) + if key == "spriteFront" then frontStream = stream end local pixelSize = size * 8 local byteLength = pixelSize * pixelSize / 4 - while #pixels < byteLength do pixels[#pixels + 1] = 0 end - while #pixels > byteLength do table.remove(pixels) end + local pixels = {} + for i = 1, byteLength do pixels[i] = stream[i] or 0 end pixels = ImageWriter.columnsToRows(pixels, size, size) self:write2bpp(pixels, pixelSize, pixelSize, rel) end) @@ -1605,6 +1842,15 @@ function RomExtractorGen2:extractPokemon() end readPic(0, tiles, "front", "spriteFront") readPic(3, 6, "back", "spriteBack") + if unownTables and frontStream then + local ok, result = pcall(self.monAnimation, self, unownTables, + index + 1, tiles, frontStream, "unown_" .. letter:lower()) + if ok then + entry.anim = result + else + self:trace(("unown %s anim: %s"):format(letter, tostring(result))) + end + end letters[letter] = entry end out.UNOWN.letters = letters @@ -1614,6 +1860,7 @@ function RomExtractorGen2:extractPokemon() or (letters.A and letters.A.spriteFront) out.UNOWN.spriteBack = out.UNOWN.spriteBack or (letters.A and letters.A.spriteBack) + out.UNOWN.anim = out.UNOWN.anim or (letters.A and letters.A.anim) end self:write("pokemon", out) @@ -1629,6 +1876,11 @@ end -- FillTitleScreenPals / title_bg_gold.pal colorize BG zones; title_fg.pal -- tints Ho-Oh. Clouds (rows 11+) scroll via LYOverrides in retail. function RomExtractorGen2:extractTitle() + -- Crystal has no title tilemap: DrawTitleGraphic composes on the fly + -- (../pokecrystal/engine/movie/title.asm:104-118). + if self.edition == "crystal" then + return require("src.import.CrystalMovie").extractTitle(self) + end self:beginStage("Title screen") local function tilesFrom2bpp(raw, transparent) @@ -2077,12 +2329,22 @@ local CREDITS_SCENES = { { species = "ELEKID", label = "CreditsElekidGFX", frames = 3 }, { species = "SENTRET", label = "CreditsSentretGFX", frames = 4 }, } +-- ../pokecrystal/engine/movie/credits.asm:610-613; each is $400 bytes, so all +-- four run 4 frames of 4x4 tiles rather than Gold's 3/3/3/4. +local CREDITS_SCENES_CRYSTAL = { + { species = "PICHU", label = "CreditsPichuGFX", frames = 4 }, + { species = "SMOOCHUM", label = "CreditsSmoochumGFX", frames = 4 }, + { species = "DITTO", label = "CreditsDittoGFX", frames = 4 }, + { species = "IGGLYBUFF", label = "CreditsIgglybuffGFX", frames = 4 }, +} local CREDITS_BORDER_TILES = 9 local CREDITS_THEEND_TILES = 16 function RomExtractorGen2:extractCredits() self:beginStage("Credits") - local steps = #CREDITS_SCENES + 3 + local sceneList = (self.edition == "crystal") + and CREDITS_SCENES_CRYSTAL or CREDITS_SCENES + local steps = #sceneList + 3 local data = { generation = 2, source = "ROM:CreditsBorderGFX + CreditsGFX + TheEndGFX" @@ -2114,7 +2376,7 @@ function RomExtractorGen2:extractCredits() self:tick("Credits", 2, steps) local scenes = {} - for index, scene in ipairs(CREDITS_SCENES) do + for index, scene in ipairs(sceneList) do if self.symbols[scene.label] then local sym = self:symbol(scene.label) local raw = self.rom:bytes(sym.bank, sym.address, scene.frames * 16 * 16) @@ -2130,7 +2392,7 @@ function RomExtractorGen2:extractCredits() end self:tick("Credits", 2 + index, steps) end - if #scenes == #CREDITS_SCENES then data.scenes = scenes end + if #scenes == #sceneList then data.scenes = scenes end if self.symbols["CreditsPalettes"] then local pal = self:symbol("CreditsPalettes") @@ -2370,7 +2632,10 @@ end -- generation-2 channel driver. Banks are NOT Gen 1's {2,8,31}. function RomExtractorGen2:extractAudio(maps) self:beginStage("Sound programs") + -- "Songs 5" and "Crystal Sound Effects" are bank $5e + -- (../pokecrystal/layout.link:246-249). local bankOrder = { 0x07, 0x33, 0x3a, 0x3b, 0x3c, 0x3d } + if self.edition == "crystal" then bankOrder[#bankOrder + 1] = 0x5e end local chunks = {} for index, bank in ipairs(bankOrder) do local first = Rom.offset(bank, 0x4000) + 1 @@ -2410,10 +2675,17 @@ function RomExtractorGen2:extractAudio(maps) local wave = self:symbol("WaveSamples") local drums = self:symbol("Drumkits") local mapSongs = {} + -- GetMapMusic (home/map.asm:2204-2207) takes MUSIC_MAHOGANY_MART and the + -- RADIO_TOWER_MUSIC bit off the table before it ever indexes Music. On Gold + -- both fall outside NUM_MUSIC_SONGS and drop out on their own; Crystal's + -- MUSIC_MAHOGANY_MART aliases MUSIC_SUICUNE_BATTLE ($64), which resolves. + -- ../pokecrystal/constants/music_constants.asm:112,120. + local MUSIC_MAHOGANY_MART, RADIO_TOWER_MUSIC = 100, 0x80 if maps then for mapId, def in pairs(maps) do local id = def.music - if type(id) == "number" and id > 0 then + if type(id) == "number" and id > 0 + and id ~= MUSIC_MAHOGANY_MART and id < RADIO_TOWER_MUSIC then local label = musicOrder[id + 1] if label and songs[label] then mapSongs[mapId] = label @@ -2738,7 +3010,9 @@ function RomExtractorGen2:readEventTables() -- so the cache carries the items.lua key the rest of the port indexes with. local itemOrder = self.manifest.constants.itemOrder or {} local tradeRows = {} - for row = 0, NUM_NPC_TRADES - 1 do + local tradeCount = (self.edition == "crystal") + and NUM_NPC_TRADES_CRYSTAL or NUM_NPC_TRADES + for row = 0, tradeCount - 1 do local base = trades.address + row * NPCTRADE_STRUCT_LENGTH local raw = self.rom:bytes(trades.bank, base, 3) local dvBase = base + 3 + MON_NAME_LENGTH @@ -2770,7 +3044,10 @@ function RomExtractorGen2:readEventTables() -- The trade conversation's five lines, one set per TRADE_DIALOGSET_*. -- PrintTradeText is `TradeTexts + 6 * dialog + 2 * dialogset`, so the table -- is stored dialog-major: three INTROs, then three CANCELs, and so on. + -- Crystal's fourth dialogset makes the row stride 8 + -- (../pokecrystal/engine/events/npc_trade.asm:389-399 `ld bc, 2 * 4`). local tradeTexts = self:symbol("TradeTexts") + local tradeStride = (self.edition == "crystal") and 8 or 6 local dialogs = { "TRADE_DIALOG_INTRO", "TRADE_DIALOG_CANCEL", "TRADE_DIALOG_WRONG", "TRADE_DIALOG_COMPLETE", "TRADE_DIALOG_AFTER" } local sets = consts.tradeDialogOrder or {} @@ -2783,7 +3060,7 @@ function RomExtractorGen2:readEventTables() local row, bufRow = {}, {} for s = 1, #sets do local addr = self.rom:word(tradeTexts.bank, - tradeTexts.address + (d - 1) * 6 + (s - 1) * 2) + tradeTexts.address + (d - 1) * tradeStride + (s - 1) * 2) local buffers = {} row[sets[s]] = self:decodeGen2Text(tradeTexts.bank, addr, charmap, buffers) @@ -2872,6 +3149,35 @@ function RomExtractorGen2:readEventTables() } out.decorationOrder = consts.decoDescOrder + -- ../pokecrystal/data/events/unown_walls.asm:7 UnownWalls and :15 MenuHeaders_UnownWalls; + -- macros/coords.asm:71 `menu_coords` emits y before x. + local unownWalls = self.symbols.UnownWalls + local unownHeaders = self.symbols.MenuHeaders_UnownWalls + local unownMap = self.manifest.unownCharmap + if unownWalls and unownHeaders and unownMap then + local walls = {} + local address = unownWalls[2] + for wall = 0, NUM_UNOWN_WALLS - 1 do + local chars, word = {}, {} + while true do + local byte = self.rom:byte(unownWalls[1], address) + address = address + 1 + if byte == 0xff then break end + chars[#chars + 1] = byte + word[#word + 1] = unownMap[tostring(byte)] or "?" + end + local head = self.rom:bytes( + unownHeaders[1], unownHeaders[2] + wall * UNOWN_WALL_HEADER_SIZE, + UNOWN_WALL_HEADER_SIZE) + walls[wall + 1] = { + id = wall, word = table.concat(word), chars = chars, + flags = head[1], + y1 = head[2], x1 = head[3], y2 = head[4], x2 = head[5], + } + end + out.unownWalls = walls + end + return out end @@ -3366,7 +3672,7 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) commands[#commands + 1] = { op = "truncated", reason = "read" } break end - local info = Opcodes[opcode] + local info = self.opcodes[opcode] if not info then commands[#commands + 1] = { op = "unknown", code = opcode, @@ -3395,7 +3701,9 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) if info.name == "writetext" or info.name == "jumptext" or info.name == "jumptextfaceplayer" then cmd.text = ensureText(bank, wordFromArgs(args)) - elseif info.name == "farwritetext" then + -- farjumptext is Crystal's new $52 and carries a dba, not a dw + -- (../pokecrystal/engine/overworld/scripting.asm:318-327). + elseif info.name == "farwritetext" or info.name == "farjumptext" then local tBank, tAddr = dbaFromArgs(args) cmd.text = ensureText(tBank, tAddr) elseif info.name == "checkevent" or info.name == "setevent" @@ -3613,7 +3921,7 @@ function RomExtractorGen2:extractInitialEvents() if not romAddrOk(bank, pc) then break end local okByte, opcode = pcall(self.rom.byte, self.rom, bank, pc) if not okByte then break end - local info = Opcodes[opcode] + local info = self.opcodes[opcode] if not info then break end local size = info.size local okArgs, args = true, {} @@ -3678,10 +3986,7 @@ local PREDEFPAL_GAMEFREAK_LOGO_BG = 78 -- indexes straight off it. The star is 1x2 tiles and the OAM set mirrors it -- to make the other half; the sparkle strip is 3 frames in a row. function RomExtractorGen2:splashGfx() - if not (self.symbols.GameFreakLogoGFX - and self.symbols.GameFreakLogoStarsGFX) then - return nil - end + if not self.symbols.GameFreakLogoGFX then return nil end local gfx = self:symbol("GameFreakLogoGFX") local raw = self.rom:bytes(gfx.bank, gfx.address, 28 * 8) local presents, logo = {}, {} @@ -3695,21 +4000,47 @@ function RomExtractorGen2:splashGfx() -- finds a logo image there, and now it is the right one. self:save(inkFrom1bpp(logo, 24, 40), "intro/gamefreak_logo.png") - local stars = self:symbol("GameFreakLogoStarsGFX") - local starRaw = self.rom:bytes(stars.bank, stars.address, 2 * 16) - local sparkleRaw = self.rom:bytes(stars.bank, stars.address + 2 * 16, 3 * 16) - -- 2bpp with shade 0 transparent, the way every OBJ sheet is written. - self:write2bpp(starRaw, 8, 16, "splash/star.png", true) - self:write2bpp(sparkleRaw, 24, 8, "splash/sparkle.png", true) - - return { + local out = { presents = "assets/generated/splash/presents.png", logo = "assets/generated/splash/logo.png", - star = "assets/generated/splash/star.png", - sparkle = "assets/generated/splash/sparkle.png", obPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_OB), bgPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_BG), } + + if self.symbols.GameFreakLogoStarsGFX then + local stars = self:symbol("GameFreakLogoStarsGFX") + local starRaw = self.rom:bytes(stars.bank, stars.address, 2 * 16) + local sparkleRaw = self.rom:bytes( + stars.bank, stars.address + 2 * 16, 3 * 16) + -- 2bpp with shade 0 transparent, the way every OBJ sheet is written. + self:write2bpp(starRaw, 8, 16, "splash/star.png", true) + self:write2bpp(sparkleRaw, 24, 8, "splash/sparkle.png", true) + out.star = "assets/generated/splash/star.png" + out.sparkle = "assets/generated/splash/sparkle.png" + end + + -- Crystal has no stars: GameFreakPresentsInit decompresses ditto.2bpp.lz and + -- requests 8 tiles to vTiles0 and 8 more from +$80 tiles to vTiles1 + -- (../pokecrystal/engine/movie/splash.asm:61-88). The whole 256-tile sheet + -- is written as one 16-wide page so tile n is (n % 16, n / 16). + if self.symbols.GameFreakDittoGFX then + local ditto = self:decompressLz3Symbol("GameFreakDittoGFX") + local tiles = math.floor(#ditto / 16) + local rows = math.ceil(tiles / 16) + while #ditto < rows * 16 * 16 do ditto[#ditto + 1] = 0 end + self:write2bpp(ditto, 128, rows * 8, "splash/ditto.png", true) + out.ditto = "assets/generated/splash/ditto.png" + out.dittoTiles = tiles + out.dittoTilesWide = 16 + end + -- gfx/splash/ditto_fade.pal, one colour per step of the pink-to-orange fade + -- Ditto runs as it becomes the logo. + if self.symbols.GameFreakDittoPaletteFade then + local fade = self:symbol("GameFreakDittoPaletteFade") + out.dittoFade = self:colors(fade.bank, fade.address, 16) + end + + return out end -- The engine's own strings, keyed by the label the disassembly gives them. @@ -3764,17 +4095,39 @@ function RomExtractorGen2:extractOakSpeech(pokemon) if self.symbols.Shrink2Pic then pcall(self.writeCompressedPic, self, "Shrink2Pic", 7, "intro/shrink2.png") end + -- The player pic OakSpeech draws: Crystal's DrawIntroPlayerPic picks + -- Chris/Kris by gender (../pokecrystal/engine/gfx/player_gfx.asm:170-201), + -- both raw column-major 7x7 2bpp (../pokecrystal/Makefile:319,321). + local playerPic, playerPicFemale = nil, nil + for key, label in pairs({ chris = "ChrisPic", kris = "KrisPic" }) do + if self.symbols[label] then + local sym = self:symbol(label) + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, 7 * 7 * 16), 7, 7) + self:write2bpp(pixels, 56, 56, "intro/" .. key .. ".png") + if key == "chris" then + playerPic = "assets/generated/intro/chris.png" + else + playerPicFemale = "assets/generated/intro/kris.png" + end + end + end local splash = self:splashGfx() - local marill = pokemon and pokemon.MARILL + -- OakSpeech's demo mon: MARILL on Gold (../pokegold/engine/menus/ + -- intro_menu.asm:519), WOOPER on Crystal (../pokecrystal/engine/menus/ + -- intro_menu.asm:652). + local demoSpecies = (self.edition == "crystal") and "WOOPER" or "MARILL" + local demoMon = pokemon and pokemon[demoSpecies] local data = { generation = 2, source = "ROM:OakSpeech (_OakText1-7, PokemonProfPic, CalPic)", music = "Music_Route30", - demoSpecies = "MARILL", + demoSpecies = demoSpecies, oakPic = "assets/generated/intro/oak.png", - playerPic = "assets/generated/intro/cal.png", - marillPic = marill and marill.spriteFront - or "assets/generated/battle/front/marill.png", + playerPic = playerPic or "assets/generated/intro/cal.png", + playerPicFemale = playerPicFemale, + marillPic = demoMon and demoMon.spriteFront + or ("assets/generated/battle/front/%s.png"):format(demoSpecies:lower()), shrink1 = "assets/generated/intro/shrink1.png", shrink2 = "assets/generated/intro/shrink2.png", gamefreakLogo = "assets/generated/intro/gamefreak_logo.png", @@ -4201,6 +4554,56 @@ function RomExtractorGen2:readRoamMaps() return out end +-- InitRoamMons (engine/overworld/wildmons.asm) is straight-line +-- `ld a, n` / `ld [wRoamMonN], a` writes with one `xor a` before the HP +-- pair, so the roster is read out of the code rather than a table: Gold seeds +-- three beasts (:488-529), Crystal two (../pokecrystal/.../wildmons.asm: +-- 493-524). roam_struct is species, level, group, number, hp, dvs +-- (macros/ram.asm:218-225), so the writes land at base+0..base+4, stride 7. +local ROAMMON_STRUCT_LENGTH = 7 +local LD_A_N, LD_NN_A, XOR_A, RET = 0x3e, 0xea, 0xaf, 0xc9 + +function RomExtractorGen2:readRoamMons() + local symbol = self:symbol("InitRoamMons") + local writes, order = {}, {} + local at, acc = symbol.address, 0 + for _ = 1, 128 do + if not romAddrOk(symbol.bank, at + 2) then return nil end + local op = self.rom:byte(symbol.bank, at) + if op == RET then break end + if op == XOR_A then + acc, at = 0, at + 1 + elseif op == LD_A_N then + acc, at = self.rom:byte(symbol.bank, at + 1), at + 2 + elseif op == LD_NN_A then + local dest = self.rom:word(symbol.bank, at + 1) + if writes[dest] == nil then order[#order + 1] = dest end + writes[dest] = acc + at = at + 3 + else + return nil + end + end + -- wRoamMon1..3 are consecutive (../pokecrystal/ram/wram.asm:3486-3488), so + -- the first destination written is wRoamMon1Species. + local base = order[1] + if not base then return nil end + local roamers = {} + for slot = 0, 2 do + local at2 = base + slot * ROAMMON_STRUCT_LENGTH + local species = writes[at2] + if not species or species == 0 then break end + roamers[#roamers + 1] = { + species = self:speciesName(species), + level = writes[at2 + 1], + mapGroup = writes[at2 + 2], + mapNumber = writes[at2 + 3], + map = self:mapNameByIds(writes[at2 + 2] or 0, writes[at2 + 3] or 0), + } + end + return (#roamers > 0) and roamers or nil +end + -- data/wild/bug_contest_mons.asm. A shape of its own, and deliberately not -- read through readGrassTable: there is no map key, no per-time rate and no -- seven-slot list, just `db %, species, min, max` rows that @@ -4399,6 +4802,12 @@ function RomExtractorGen2:extractEncounters() local swarmWater = self.symbols.SwarmWaterWildMons and self:readWaterTable("SwarmWaterWildMons") or nil local roamMaps = self.symbols.RoamMaps and self:readRoamMaps() or nil + local roamMons = nil + if self.symbols.InitRoamMons then + local ok, result = pcall(self.readRoamMons, self) + roamMons = ok and result or nil + if not ok then self:trace("roam mons: " .. tostring(result)) end + end local data = { generation = 2, @@ -4414,6 +4823,7 @@ function RomExtractorGen2:extractEncounters() swarmGrass = swarmGrass, swarmWater = swarmWater, roamMaps = roamMaps, + roamMons = roamMons, } self:trace("writing encounters.lua") self:write("encounters", data) @@ -4421,6 +4831,150 @@ function RomExtractorGen2:extractEncounters() return data end +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:29-38 and +-- :117-119. Both rejection loops are `maskbits N / cp N / jr nc, .resample`, +-- which rgbasm lays down as E6 mask / FE count / 30 rel -- so the ceiling the +-- cart really samples against is read here rather than written down. Crystal +-- 1.0 and 1.1 differ in exactly this byte (:33-37). +function RomExtractorGen2:btSampleCeiling(label) + local symbol = self:symbol(label) + local raw = self.rom:bytes(symbol.bank, symbol.address, 24) + for index = 1, #raw - 4 do + if raw[index] == 0xe6 and raw[index + 2] == 0xfe + and raw[index + 4] == 0x30 then + return raw[index + 3] + end + end + error("battle tower sample ceiling not found at " .. label) +end + +-- One `party_struct` (../pokecrystal/macros/ram.asm party_struct, laid out by +-- ../pokecrystal/constants/pokemon_data_constants.asm:75-113). Every word in +-- it is big-endian, which is what `bigdw` / `bigdt` in parties.asm emit. +function RomExtractorGen2:readBattleTowerMon(bank, address, moveOrder, itemOrder) + local raw = self.rom:bytes(bank, address, PARTYMON_STRUCT_LENGTH) + local function word(index) return raw[index] * 0x100 + raw[index + 1] end + local moves, pp = {}, {} + for slot = 0, 3 do + local moveId = raw[3 + slot] + if moveId ~= 0 then + moves[#moves + 1] = moveOrder[moveId] or moveId + -- MON_PP's top two bits are the PP Ups (constants/pokemon_data_ + -- constants.asm:135 PP_UP_MASK); no Tower row uses them, and masking + -- here keeps a count out of the port's pp field either way. + pp[#pp + 1] = raw[24 + slot] % 64 + end + end + local item = itemOrder[raw[2]] + if item == "NO_ITEM" then item = nil end + return { + species = self:speciesName(raw[1]), + item = item, + moves = moves, + pp = pp, + otId = word(7), + experience = raw[9] * 0x10000 + raw[10] * 0x100 + raw[11], + statExp = { + hp = word(12), attack = word(14), defense = word(16), + speed = word(18), special = word(20), + }, + -- MON_DVS: two nibble pairs, attack/defense then speed/special. + dvs = { + attack = math.floor(raw[22] / 16), defense = raw[22] % 16, + speed = math.floor(raw[23] / 16), special = raw[23] % 16, + }, + happiness = raw[28], + level = raw[32], + -- MON_HP / MON_MAXHP / MON_STATS, stored rather than recomputed: the cart + -- copies these bytes straight into wOTPartyMon. + hp = word(35), + maxHp = word(37), + stats = { + hp = word(37), attack = word(39), defense = word(41), + speed = word(43), specialAttack = word(45), specialDefense = word(47), + }, + } +end + +-- The Battle Tower roster. A separate table from Trainers/TrainerGroups in +-- every way: the trainers are 70 fixed-width name+class rows +-- (../pokecrystal/data/battle_tower/classes.asm:1-4 `bt_trainer`), and their +-- parties are not read from the trainer row at all -- LoadRandomBattleTowerMon +-- draws three FULLY BUILT party_structs out of BattleTowerMons, which is ten +-- level groups of twenty-one mons carrying their own DVs, stat exp, PP and +-- final stats. So none of extractTrainers' TRAINERTYPE_* walk applies here. +-- +-- Gold and Silver have neither symbol and get nil. +function RomExtractorGen2:readBattleTowerRoster(consts, charmap) + if not (self.symbols["BattleTowerTrainers"] + and self.symbols["BattleTowerMons"]) then + return nil + end + local classOrder = consts.trainerClassOrder or {} + local spriteOrder = consts.spriteOrder or {} + local moveOrder = consts.moveOrder or {} + local itemOrder = consts.itemOrder or {} + local trainerSym = self:symbol("BattleTowerTrainers") + local monSym = self:symbol("BattleTowerMons") + -- BattleTowerMons is the label straight after BattleTowerTrainers + -- (../pokecrystal/engine/events/battle_tower/load_trainer.asm:210-212 + -- includes classes.asm then parties.asm), so the gap IS the row count. + local uniqueTrainers = + math.floor((monSym.address - trainerSym.address) / NAME_LENGTH) + local uniqueMon = self:btSampleCeiling("LoadRandomBattleTowerMon.resample") + local trainers = {} + for index = 0, uniqueTrainers - 1 do + local address = trainerSym.address + index * NAME_LENGTH + local raw = self.rom:bytes(trainerSym.bank, address, NAME_LENGTH - 1) + local classId = self.rom:byte(trainerSym.bank, address + NAME_LENGTH - 1) + trainers[index + 1] = { + index = index, + name = self.rom:decodeText(raw, charmap, 0x50), + class = classId, + classId = classOrder[classId + 1], + } + end + local groups = {} + for group = 1, BT_LEVEL_GROUPS do + local rows = {} + for slot = 0, uniqueMon - 1 do + local address = monSym.address + + ((group - 1) * uniqueMon + slot) * NICKNAMED_MON_STRUCT_LENGTH + rows[slot + 1] = self:readBattleTowerMon( + monSym.bank, address, moveOrder, itemOrder) + end + groups[group] = rows + end + -- ../pokecrystal/data/trainers/sprites.asm:1-3, one SPRITE_* per trainer class starting at + -- class 1; LoadOpponentTrainerAndPokemonWithOTSprite indexes it with + -- `wBT_OTTrainerClass - 1` (battle_tower.asm:1540-1550). The table stops + -- one row short of the class list: `assert_table_length NUM_TRAINER_CLASSES + -- - 1 ; exclude MYSTICALMAN` (../pokecrystal/data/trainers/sprites.asm:70). + local sprites = {} + if self.symbols["BTTrainerClassSprites"] then + local spriteSym = self:symbol("BTTrainerClassSprites") + for classId = 1, #classOrder - 2 do + local byte = self.rom:byte(spriteSym.bank, spriteSym.address + classId - 1) + sprites[classOrder[classId + 1]] = spriteOrder[byte] or byte + end + end + return { + source = "ROM:BattleTowerTrainers + BattleTowerMons" + .. " + BTTrainerClassSprites", + partyLength = BT_PARTY_LENGTH, + levelGroups = BT_LEVEL_GROUPS, + uniqueMon = uniqueMon, + uniqueTrainers = uniqueTrainers, + -- The trainer draw's own ceiling, which on Crystal 1.0 is uniqueMon and + -- not uniqueTrainers (load_trainer.asm:33-37). + sampleTrainers = + self:btSampleCeiling("LoadOpponentTrainerAndPokemon.resample"), + trainers = trainers, + groups = groups, + classSprites = sprites, + } +end + -- Trainers. TrainerGroups is indexed by trainer class - 1 (the table starts -- at FALKNER = 1); each group is a run of variable-length parties whose shape -- depends on the party's own TRAINERTYPE_* byte, ending at -1. @@ -4566,6 +5120,7 @@ function RomExtractorGen2:extractTrainers() } self:tick("Trainers", classIndex, #classOrder) end + out.battleTower = self:readBattleTowerRoster(consts, charmap) self:write("trainers", out) return out end @@ -4910,6 +5465,11 @@ function RomExtractorGen2:introPalettes() end function RomExtractorGen2:extractIntro() + -- Crystal's intro is a different program with its own asset set + -- (../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, :1678-1777 GFX). + if self.edition == "crystal" then + return require("src.import.CrystalMovie").extractIntro(self) + end self:beginStage("Intro movie") local out = { generation = 2, source = "ROM:Intro_*GFX/Tilemap/Meta" } @@ -5045,6 +5605,25 @@ function RomExtractorGen2:extractMenuGfx() if ok then hud.playerBack = "assets/generated/battle/player_back.png" end end + -- GetKrisBackpic, six tiles square like Chris but stored raw rather than lz3 + -- (../pokecrystal/engine/gfx/player_gfx.asm:209-217). + if self.symbols["KrisBackpic"] then + local sym = self:symbol("KrisBackpic") + local ok = pcall(function() + local size = BACK_PIC_TILES * 8 + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, + BACK_PIC_TILES * BACK_PIC_TILES * 16), + BACK_PIC_TILES, BACK_PIC_TILES) + self:save(ImageWriter.matteColor0( + ImageWriter.decode2bpp(pixels, size, size)), + "battle/player_back_female.png") + end) + if ok then + hud.playerBackFemale = "assets/generated/battle/player_back_female.png" + end + end + -- GetTrainerBackpic's "Special exception for Dude": the catching tutorial -- draws DudeBackpic in that same box, from the same bank and at the same six -- tiles square (src/core/gen2/CatchTutorial.lua). @@ -5069,8 +5648,7 @@ function RomExtractorGen2:extractMenuGfx() -- index 1 is TRAINER_NONE (class 0), which the `dec a` skips. if index >= 2 then local base = symbol.address + (index - 2) * 3 - local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base)] - or self.rom:byte(symbol.bank, base) + local bank = self:picBank(self.rom:byte(symbol.bank, base)) local address = self.rom:word(symbol.bank, base + 1) local rel = ("battle/trainers/%s.png"):format(class:lower()) local ok, err = pcall(function() @@ -5095,6 +5673,24 @@ function RomExtractorGen2:extractMenuGfx() end end end + -- HOF_LoadTrainerFrontpic sets wTrainerClass to CHRIS or KRIS and loads + -- ChrisPic / KrisPic (../pokecrystal/engine/gfx/player_gfx.asm:138,203,206). + for class, label in pairs({ CHRIS = "ChrisPic", KRIS = "KrisPic" }) do + if self.symbols[label] then + local sym = self:symbol(label) + local rel = ("battle/trainers/%s.png"):format(class:lower()) + local ok, err = pcall(function() + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, 7 * 7 * 16), 7, 7) + self:write2bpp(pixels, 56, 56, rel) + end) + if ok then + pics[class] = "assets/generated/" .. rel + else + self:trace(("trainer pic %s: %s"):format(class, tostring(err))) + end + end + end hud.trainerPics = pics end @@ -5181,10 +5777,23 @@ function RomExtractorGen2:extractMenuGfx() -- constants/item_data_constants.asm *_POCKET order. pack.pocketOrder = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" } - local packPals = self:symbol("_CGB_PackPals.PackPals") - pack.palettes = {} - for i = 0, 5 do - pack.palettes[i + 1] = self:colors(packPals.bank, packPals.address + i * 8, 4) + -- Crystal splits the pack palette by player gender + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:818,821); the FillBoxCGB zones + -- below are byte-identical in both trees (:792-811 vs pokegold :715-734). + local function packPalettes(symbol) + local out2 = {} + for i = 0, 5 do + out2[i + 1] = self:colors(symbol.bank, symbol.address + i * 8, 4) + end + return out2 + end + local packPals = self.symbols["_CGB_PackPals.PackPals"] + and self:symbol("_CGB_PackPals.PackPals") + or self:symbol("_CGB_PackPals.ChrisPackPals") + pack.palettes = packPalettes(packPals) + if self.symbols["_CGB_PackPals.KrisPackPals"] then + pack.palettesFemale = + packPalettes(self:symbol("_CGB_PackPals.KrisPackPals")) end -- _CGB_PackPals' FillBoxCGB calls, as {x, y, width, height, palette}. pack.paletteZones = { @@ -5848,10 +6457,20 @@ function RomExtractorGen2:pokegearGfx() kanto = self:readFlatTilemap("KantoMap", cells), } - local pals = self:symbol("PokegearPals") - gear.palettes = {} - for i = 0, 5 do - gear.palettes[i + 1] = self:colors(pals.bank, pals.address + i * 8, 4) + -- Crystal picks the set by player gender + -- (../pokecrystal/engine/gfx/color.asm:1330,1333). + local function gearPalettes(symbol) + local out2 = {} + for i = 0, 5 do + out2[i + 1] = self:colors(symbol.bank, symbol.address + i * 8, 4) + end + return out2 + end + local pals = self.symbols["PokegearPals"] and self:symbol("PokegearPals") + or self:symbol("MalePokegearPals") + gear.palettes = gearPalettes(pals) + if self.symbols["FemalePokegearPals"] then + gear.palettesFemale = gearPalettes(self:symbol("FemalePokegearPals")) end -- TownMapPals.PalMap: 48 bytes covering tiles $00..$5f, 1-based so the value -- indexes gear.palettes directly. @@ -5878,13 +6497,45 @@ end -- of BadgeGFX with TrainerCard_JohtoBadgesOAM as their template table. function RomExtractorGen2:trainerCardGfx() local card = {} - local chris = self:symbol("ChrisPicAndTrainerCardGFX") - -- 41 tiles padded out to three 16-tile rows so the sheet is addressable as - -- (id % 16, id / 16) like every other one here. - local cardTiles = self.rom:bytes(chris.bank, chris.address, 41 * 16) - while #cardTiles < 48 * 16 do cardTiles[#cardTiles + 1] = 0 end - self:write2bpp(cardTiles, 128, 24, "trainer_card/card.png") - card.card = "assets/generated/trainer_card/card.png" + -- Crystal keeps the same $23-portrait-then-6-frame-tiles VRAM layout but + -- splits the source into three labels and a gender pair: GetCardPic copies + -- $23 tiles of Chris/KrisCardPic to vTiles2 $00 and 6 tiles of + -- TrainerCardGFX to $23 (../pokecrystal/engine/gfx/player_gfx.asm:96-112). + local function cardSheet(portraitLabel, frameLabel, relative) + local cardTiles + if frameLabel then + local portrait = self:symbol(portraitLabel) + local frame = self:symbol(frameLabel) + -- Crystal builds the 5x7 portrait with --columns + -- (../pokecrystal/Makefile:324-325); the 6 frame tiles have no such rule. + cardTiles = ImageWriter.columnsToRows( + self.rom:bytes(portrait.bank, portrait.address, 35 * 16), 5, 7) + for _, byte in ipairs(self.rom:bytes(frame.bank, frame.address, 6 * 16)) + do + cardTiles[#cardTiles + 1] = byte + end + else + local sym = self:symbol(portraitLabel) + cardTiles = self.rom:bytes(sym.bank, sym.address, 41 * 16) + end + -- 41 tiles padded out to three 16-tile rows so the sheet is addressable as + -- (id % 16, id / 16) like every other one here. + while #cardTiles < 48 * 16 do cardTiles[#cardTiles + 1] = 0 end + self:write2bpp(cardTiles, 128, 24, relative) + return "assets/generated/" .. relative + end + + if self.symbols["ChrisPicAndTrainerCardGFX"] then + card.card = cardSheet("ChrisPicAndTrainerCardGFX", nil, + "trainer_card/card.png") + else + card.card = cardSheet("ChrisCardPic", "TrainerCardGFX", + "trainer_card/card.png") + if self.symbols["KrisCardPic"] then + card.cardFemale = cardSheet("KrisCardPic", "TrainerCardGFX", + "trainer_card/card_f.png") + end + end card.cardTilesWide = 16 card.portraitTiles = 35 card.portraitWide = 5 @@ -6204,6 +6855,254 @@ function RomExtractorGen2:readBattleAnimGfx() return out end +-- Mobile System GB art: `tiles` is the ROM block's tile count and `wide` the +-- tile width its own PNG uses (../pokecrystal/Makefile:340-350). +local MOBILE_SHEETS = { + -- ../pokecrystal/mobile/mobile_5c.asm:290,293,296,753,866 + { key = "asciiFont", file = "ascii_font", + label = "AsciiFontGFX", tiles = 110, wide = 16 }, + { key = "pichuAnimated", file = "pichu_animated", + label = "PichuAnimatedMobileGFX", tiles = 193, wide = 16, lz = true }, + { key = "electroBall", file = "electro_ball", + label = "ElectroBallMobileGFX", tiles = 83, wide = 16, lz = true }, + { key = "pichuBorder", file = "pichu_border", + label = "PichuBorderMobileGFX", tiles = 24, wide = 4 }, + { key = "stadium2N64", file = "stadium2_n64", + label = "Stadium2N64GFX", tiles = 73, wide = 11 }, + -- ../pokecrystal/mobile/mobile_5e.asm:2,5,8,11,14,18,931,934,941 + { key = "card", file = "card", label = "MobileCardGFX", + tiles = 32, wide = 16 }, + { key = "chrisSilhouette", file = "chris_silhouette", + label = "ChrisSilhouetteGFX", tiles = 35, wide = 5 }, + { key = "krisSilhouette", file = "kris_silhouette", + label = "KrisSilhouetteGFX", tiles = 35, wide = 5 }, + { key = "card2", file = "card_2", label = "MobileCard2GFX", + tiles = 23, wide = 12 }, + { key = "cardLargeSprite", file = "card_large_sprite", + label = "CardLargeSpriteAndFolderGFX", tiles = 8, wide = 4 }, + { key = "cardFolder", file = "card_folder", + label = "CardLargeSpriteAndFolderGFX", tiles = 65, wide = 6, + offset = 8 * 16 }, + { key = "cardSprite", file = "card_sprite", + label = "CardSpriteGFX", tiles = 4, wide = 2 }, + { key = "dialpad", file = "dialpad", label = "DialpadGFX", + tiles = 76, wide = 16 }, + { key = "dialpadCursor", file = "dialpad_cursor", + label = "DialpadCursorGFX", tiles = 5, wide = 2 }, + { key = "cardList", file = "card_list", + label = "MobileCardListGFX", tiles = 24, wide = 12 }, + -- ../pokecrystal/mobile/mobile_5f.asm:84,87,3528 + { key = "haveWant", file = "havewant", label = "HaveWantGFX", + tiles = 144, wide = 16 }, + { key = "select", file = "select", label = "MobileSelectGFX", + tiles = 32, wide = 4 }, + { key = "pokemonNews", file = "pokemon_news", + label = "PokemonNewsGFX", tiles = 72, wide = 8 }, + -- ../pokecrystal/mobile/mobile_5b.asm:206,758 + { key = "splash", file = "mobile_splash", + label = "MobileSystemSplashScreen_InitGFX.Tiles", tiles = 76, wide = 14 }, + { key = "adapterCheck", file = "mobile_splash_check", + label = "MobileAdapterCheckGFX", tiles = 48, wide = 16 }, + -- ../pokecrystal/mobile/mobile_42.asm:1733,1736,1757,1760 and + -- ../pokecrystal/mobile/mobile_40.asm:6921 + { key = "trade", file = "mobile_trade", label = "MobileTradeGFX", + tiles = 128, wide = 8, lz = true }, + { key = "tradeSprites", file = "mobile_trade_sprites", + label = "MobileTradeSpritesGFX", tiles = 16, wide = 4, lz = true }, + { key = "cable1", file = "mobile_cable_1", + label = "MobileCable1GFX", tiles = 16, wide = 4 }, + { key = "cable2", file = "mobile_cable_2", + label = "MobileCable2GFX", tiles = 16, wide = 4 }, + { key = "tradeLights", file = "mobile_trade_lights", + label = "MobileTradeLightsGFX", tiles = 4, wide = 2 }, + -- ../pokecrystal/mobile/mobile_45_sprite_engine.asm:311 and + -- ../pokecrystal/mobile/mobile_41.asm:1115 + { key = "dialing", file = "dialing", label = "MobileDialingGFX", + tiles = 20, wide = 2 }, + { key = "dialingFrame", file = "dialing_frame", + label = "MobileDialingFrameGFX", tiles = 8, wide = 2 }, + -- ../pokecrystal/mobile/mobile_22.asm:518 and + -- ../pokecrystal/mobile/fixed_words.asm:3231 + { key = "ezChatCursor", file = "ez_chat_cursor", + label = "EZChatCursorGFX", tiles = 2, wide = 1 }, + { key = "selectStart", file = "select_start", + label = "SelectStartGFX", tiles = 6, wide = 3 }, + -- ../pokecrystal/mobile/mobile_12.asm:1007,1010, the only 1bpp pair + { key = "upArrow", file = "up_arrow", label = "MobileUpArrowGFX", + tiles = 1, wide = 1, bpp = 1 }, + { key = "downArrow", file = "down_arrow", label = "MobileDownArrowGFX", + tiles = 1, wide = 1, bpp = 1 }, + -- ../pokecrystal/engine/menus/main_menu.asm:24 and + -- ../pokecrystal/gfx/font.asm:58 + { key = "menu", file = "mobile_menu", label = "MobileMenuGFX", + tiles = 13, wide = 13 }, + { key = "phoneTiles", file = "phone_tiles", + label = "MobilePhoneTilesGFX", tiles = 17, wide = 2 }, + -- ../pokecrystal/engine/link/mystery_gift.asm:1606,1920,1923 + { key = "mysteryGift", file = "mystery_gift/mystery_gift", + label = "MysteryGiftGFX", tiles = 67, wide = 16 }, + { key = "cardTrade", file = "mystery_gift/card_trade", + label = "CardTradeGFX", tiles = 64, wide = 16 }, + { key = "cardTradeSprite", file = "mystery_gift/card_sprite", + label = "CardTradeSpriteGFX", tiles = 8, wide = 4 }, +} + +local MOBILE_MAPS = { + -- ../pokecrystal/mobile/mobile_5c.asm:756,759,762,765,768,771,874,878 + { key = "passwordTop", file = "password_top", + label = "PasswordTopTilemap", bytes = 140 }, + { key = "passwordBottom", file = "password_bottom", + label = "PasswordBottomTilemap", bytes = 220 }, + { key = "passwordShift", file = "password_shift", + label = "PasswordShiftTilemap", bytes = 140 }, + { key = "passwordAttrmap", file = "password_attrmap", + label = "MobilePasswordAttrmap", bytes = 360 }, + { key = "centerTilemap", file = "mobile_center_tilemap", + label = "ChooseMobileCenterTilemap", bytes = 360 }, + { key = "centerAttrmap", file = "mobile_center_attrmap", + label = "ChooseMobileCenterAttrmap", bytes = 360 }, + { key = "stadium2N64Tilemap", file = "stadium2_n64_tilemap", + label = "Stadium2N64Tilemap", bytes = 360 }, + { key = "stadium2N64Attrmap", file = "stadium2_n64_attrmap", + label = "Stadium2N64Attrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_5e.asm:925,928 + { key = "dialpadTilemap", file = "dialpad_tilemap", + label = "DialpadTilemap", bytes = 360 }, + { key = "dialpadAttrmap", file = "dialpad_attrmap", + label = "DialpadAttrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_5f.asm:91,3534 + { key = "haveWantMap", file = "havewant_map", + label = "HaveWantMap", bytes = 1136 }, + { key = "newsAttrmap", file = "pokemon_news_attrmap", + label = "PokemonNewsTileAttrmap", bytes = 1128 }, + -- ../pokecrystal/mobile/mobile_5b.asm:209,212 + { key = "splashTilemap", file = "mobile_splash_tilemap", + label = "MobileSystemSplashScreen_InitGFX.Tilemap", bytes = 360 }, + { key = "splashAttrmap", file = "mobile_splash_attrmap", + label = "MobileSystemSplashScreen_InitGFX.Attrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_45_2.asm:1368,1369, one label over both + { key = "pichuBorderTilemap", file = "pichu_border_tilemap", + label = "PichuBorderMobileTilemapAttrmap", bytes = 384 }, + { key = "pichuBorderAttrmap", file = "pichu_border_attrmap", + label = "PichuBorderMobileTilemapAttrmap", bytes = 384, offset = 384 }, + -- ../pokecrystal/mobile/mobile_42.asm:1739,1742 + { key = "tradeTilemap", file = "mobile_trade_tilemap", + label = "MobileTradeTilemapLZ", bytes = 1024, lz = true }, + { key = "tradeAttrmap", file = "mobile_trade_attrmap", + label = "MobileTradeAttrmapLZ", bytes = 1024, lz = true }, +} + +-- ../pokecrystal/mobile/mobile_5b.asm:215 and the other INCLUDEd .pal files; +-- `colors` is the RGB word count, not the palette count. +local MOBILE_PALETTES = { + { key = "splash", label = "MobileSplashScreenPalettes", colors = 32 }, + { key = "password", label = "MobilePasswordPalettes", colors = 32 }, + { key = "pokemonNews", label = "PokemonNewsPalettes", colors = 32 }, + { key = "pichuBorderOB", label = "PichuBorderMobileOBPalettes", colors = 32 }, + { key = "pichuBorderBG", label = "PichuBorderMobileBGPalettes", colors = 4 }, + { key = "tradeBG", label = "MobileTradeBGPalettes", colors = 32 }, + { key = "tradeOB1", label = "MobileTradeOB1Palettes", colors = 32 }, + { key = "tradeOB2", label = "MobileTradeOB2Palettes", colors = 32 }, + { key = "tradeLights", label = "MobileTradeLightsPalettes", colors = 16 }, + { key = "adapters", label = "MobileAdapterPalettes", colors = 8 }, + { key = "unusedPulses", label = "UnusedMobilePulsePalettes", colors = 8 }, +} + +-- The mobile banks at ../pokecrystal/main.asm:187-575, which no Gold tree has. +function RomExtractorGen2:extractMobileGfx() + if self.edition ~= "crystal" then return nil end + local CacheFs = require("src.import.CacheFs") + self:trace("Mobile System GB graphics") + + local sheets, maps, palettes = {}, {}, {} + local total = #MOBILE_SHEETS + #MOBILE_MAPS + local step = 0 + + for _, row in ipairs(MOBILE_SHEETS) do + step = step + 1 + self:tick("Mobile graphics", step, total) + if self.symbols[row.label] then + local perTile = (row.bpp == 1) and 8 or 16 + local rel = ("mobile/%s.png"):format(row.file) + local ok, err = pcall(function() + local sym = self:symbol(row.label) + local offset = row.offset or 0 + local bytes + if row.lz then + bytes = self:decompressLz3Symbol(row.label) + else + bytes = self.rom:bytes(sym.bank, sym.address + offset, + row.tiles * perTile) + offset = 0 + end + local rows = math.ceil(row.tiles / row.wide) + local need = rows * row.wide * perTile + local pixels = {} + for index = 1, need do pixels[index] = bytes[offset + index] or 0 end + local width, height = row.wide * 8, rows * 8 + if row.bpp == 1 then + self:save(ImageWriter.decode1bpp(pixels, width, height), rel) + else + self:write2bpp(pixels, width, height, rel) + end + sheets[row.key] = { + path = "assets/generated/" .. rel, + tiles = row.tiles, tilesWide = row.wide, + } + end) + if not ok then + self:trace(("mobile sheet %s: %s"):format(row.key, tostring(err))) + end + end + end + + for _, row in ipairs(MOBILE_MAPS) do + step = step + 1 + self:tick("Mobile graphics", step, total) + if self.symbols[row.label] then + local rel = ("assets/generated/mobile/%s.bin"):format(row.file) + local ok, err = pcall(function() + local sym = self:symbol(row.label) + local blob + if row.lz then + local bytes = self:decompressLz3Symbol(row.label) + local chars = {} + for index = 1, row.bytes do + chars[index] = string.char(bytes[index] or 0) + end + blob = table.concat(chars) + else + local first = Rom.offset(sym.bank, sym.address) + 1 + (row.offset or 0) + blob = self.rom.data:sub(first, first + row.bytes - 1) + end + local written, writeError = CacheFs.write(rel, blob) + if not written then error(tostring(writeError)) end + maps[row.key] = { path = rel, bytes = row.bytes } + end) + if not ok then + self:trace(("mobile map %s: %s"):format(row.key, tostring(err))) + end + end + end + + for _, row in ipairs(MOBILE_PALETTES) do + if self.symbols[row.label] then + local sym = self:symbol(row.label) + palettes[row.key] = self:colors(sym.bank, sym.address, row.colors) + end + end + + local data = { + generation = 2, + source = "ROM:mobile/*.asm + engine/link/mystery_gift.asm", + sheets = sheets, + maps = maps, + palettes = palettes, + } + self:write("mobile_gfx", data) + return data +end + function RomExtractorGen2:extractBattleAnims() self:beginStage("Battle animations") local moveOrder = (self.manifest.constants or {}).moveOrder or {} @@ -6340,6 +7239,7 @@ function RomExtractorGen2:run() results.icons = self:extractIcons() results.intro = self:extractIntro() results.menuGfx = self:extractMenuGfx() + results.mobileGfx = self:extractMobileGfx() results.oakSpeech = self:extractOakSpeech(results.pokemon) results.title = self:extractTitle() results.credits = self:extractCredits() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index aa6ea4cf..20b573dc 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -155,6 +155,53 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173) "assets/generated/pc/mail_item.png", }, + crystal = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/roofs.lua", + "data/generated/sprites.lua", + "data/generated/scripts.lua", + "data/generated/text.lua", + "data/generated/rom_text.lua", + "data/generated/pokemon.lua", + "data/generated/encounters.lua", + "data/generated/tilesets.lua", + "data/generated/landmarks.lua", + "data/generated/audio.lua", + "data/generated/marts.lua", + "data/generated/oak_speech.lua", + "data/generated/title.lua", + "data/generated/intro.lua", + "assets/generated/fonts/font.png", + "assets/generated/fonts/frames.png", + "assets/generated/title/crystal_logo.png", + "assets/generated/title/crystal_wordmark.png", + "assets/generated/title/crystal_suicune.png", + "assets/generated/splash/ditto.png", + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", + "assets/generated/intro/suicune_run_sprites.png", + "assets/generated/intro/unowns_tiles.png", + "assets/generated/intro/oak.png", + "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/sprites/kris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/wooper.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/trainers/falkner.png", + "assets/generated/battle/hud/balls.png", + "assets/generated/audio/programs.bin", + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", + "assets/generated/pc/mail_item.png", + "assets/generated/trainer_card/card_f.png", + "data/generated/mobile_gfx.lua", + "assets/generated/battle/player_back_female.png", + "assets/generated/battle/trainers/kris.png", + "assets/generated/battle/trainers/chris.png", + }, } -- Same Gen 2 extract, so a Silver cache is complete when the same files exist. VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold @@ -605,6 +652,27 @@ local function isAcceptedRomSize(n) return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2 end +local function cartLabels(generation) + local out = {} + for _, id in ipairs(GameVersion.ORDER) do + if generation == nil or GameVersion.generation(id) == generation then + out[#out + 1] = GameVersion.info(id).label + end + end + return out +end + +local function cartsSlashed(generation) + return table.concat(cartLabels(generation), "/") +end + +local function cartsProse() + local names = cartLabels(nil) + local last = table.remove(names) + if #names == 0 then return last end + return table.concat(names, ", ") .. ", or " .. last +end + local function savesInboxDir(version) return SAVES_INBOX_DIR .. "/" .. tostring(version) end @@ -1849,18 +1917,18 @@ function RomImporter:startData(data, displayName) return end if not isAcceptedRomSize(#data) then - self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a " - .. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.") - :format(#data / 1024 / 1024)) + self:setError(("Expected a 1 MiB Game Boy ROM (%s) or a " + .. "2 MiB Game Boy Color ROM (%s); this file is %.2f MiB.") + :format(cartsSlashed(1), cartsSlashed(2), #data / 1024 / 1024)) return end local actualHash = sha1(data) local version = GameVersion.forSha1(actualHash) if not version then self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon " - .. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or " + .. "%s dump; patched, trimmed or " .. "\"fixed\" dumps " - .. "(tagged [b] or [BF]) never verify."):format(actualHash)) + .. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse())) return end local info = GameVersion.info(version) @@ -2965,8 +3033,10 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "red", "blue", "yellow", "gold", "silver", - "mods", "find", "skins", "bug" } + local order = { "mods", "find", "skins", "bug" } + for i = #GameVersion.ORDER, 1, -1 do + table.insert(order, 1, GameVersion.ORDER[i]) + end local idx = 1 for i, id in ipairs(order) do if id == self.tab then idx = i; break end diff --git a/src/render/MonAnim.lua b/src/render/MonAnim.lua new file mode 100644 index 00000000..15c8b52e --- /dev/null +++ b/src/render/MonAnim.lua @@ -0,0 +1,164 @@ +-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask +-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript + +local MonAnim = {} +MonAnim.__index = MonAnim + +-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic. +-- ../pokecrystal/engine/gfx/pic_animation.asm:534 +MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 } + +-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27 +MonAnim.END = 0xff +MonAnim.SETREPEAT = 0xfe +MonAnim.DOREPEAT = 0xfd + +-- .NextBit walks the row first, so bit i is row i % height, column i / height. +-- ../pokecrystal/engine/gfx/pic_animation.asm:746 +function MonAnim.tileMap(data, frame) + local tiles = data and data.tiles + if not tiles then return nil end + local count = tiles * tiles + local out = {} + for i = 1, count do out[i] = i - 1 end + if not frame or frame <= 0 then return out end + local row = data.frames and data.frames[frame] + local mask = row and data.bitmasks and data.bitmasks[row.bitmask] + if not (row and mask) then return nil end + local cursor = 1 + for i = 0, count - 1 do + local byte = mask[math.floor(i / 8) + 1] or 0 + if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then + local id = row.tiles[cursor] + if id == nil then return nil end + out[i + 1] = id + cursor = cursor + 1 + end + end + return out +end + +-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits. +-- ../pokecrystal/engine/gfx/pic_animation.asm:413 +function MonAnim.duration(param, speed) + param = param % 256 + local scaled = math.floor(param * (speed or 0) / 16) % 256 + return (scaled + param) % 256 +end + +-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18. +-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164 +local SCENES = { + battle = { "setup", "play" }, + battleSlow = { "setup2", "play" }, + menu = { "setup", "play", "wait", "idle", "play" }, +} + +MonAnim.SCENE_WAIT = 18 + +function MonAnim.scenes() return SCENES end + +function MonAnim.new(data, scene) + local steps = SCENES[scene or "battle"] + if not (data and steps and data.play and #data.play > 0) then return nil end + return setmetatable({ + data = data, + steps = steps, + step = 1, + frame = 0, + speed = 0, + script = nil, + pc = 1, + repeatTimer = 0, + waiting = false, + waitCounter = 0, + sceneWait = nil, + done = false, + }, MonAnim) +end + +function MonAnim:finished() return self.done end + +-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture. +-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435 +function MonAnim:currentFrame() return self.frame end + +function MonAnim:beginScript(rows, speed) + self.script = rows or {} + self.speed = speed + self.pc = 1 + self.repeatTimer = 0 + self.waiting = false + self.waitCounter = 0 +end + +-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim. +-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411 +function MonAnim:runScript() + if self.waiting then + self.waitCounter = (self.waitCounter - 1) % 256 + if self.waitCounter == 0 then self.waiting = false end + return false + end + for _ = 1, 256 do + local row = self.script[self.pc] + self.pc = self.pc + 1 + if row == nil then return true end + local command = row[1] + if command == MonAnim.END then + return true + elseif command == MonAnim.SETREPEAT then + self.repeatTimer = row[2] + elseif command == MonAnim.DOREPEAT then + -- .DoRepeat returns on both `ret z` (:397-406). + if self.repeatTimer == 0 then return false end + self.repeatTimer = self.repeatTimer - 1 + if self.repeatTimer == 0 then return false end + self.pc = row[2] + 1 + else + self.frame = command + self.waiting = true + self.waitCounter = MonAnim.duration(row[2], self.speed) + -- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame + -- it places is the frame the counter first ticks on. + self.waitCounter = (self.waitCounter - 1) % 256 + if self.waitCounter == 0 then self.waiting = false end + return false + end + end + return true +end + +-- One iteration of AnimateFrontpic's .loop: one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function MonAnim:update() + if self.done then return end + local step = self.steps[self.step] + if step == nil then + -- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228). + self.frame = 0 + self.done = true + return + end + if step == "setup" or step == "setup2" or step == "idle" then + local rows = (step == "idle") and self.data.idle or self.data.play + self:beginScript(rows, step == "setup2" and 4 or 0) + self.step = self.step + 1 + return + end + if step == "wait" then + self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1 + if self.sceneWait <= 0 then + self.sceneWait = nil + self.step = self.step + 1 + end + return + end + if self:runScript() then + -- PokeAnim_Play redraws the base picture as the script ends (:196-205). + self.frame = 0 + self.step = self.step + 1 + end +end + +return MonAnim diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index e022faba..8181e048 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -21,6 +21,7 @@ -- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") +local GameVersion = require("src.core.GameVersion") local SaveConvert = {} @@ -228,10 +229,11 @@ SaveConvert.mergeDefaults = mergeDefaults -- codec exists yet. Both directions answer with a plain message the -- launcher's save card renders as-is, instead of pushing a Gen 2 save -- table through Gen 1 offsets and surfacing a codec traceback. -local GEN2_SAV_UNSUPPORTED = { - gold = "Pokemon Gold", - silver = "Pokemon Silver", -} +local function gen2CartName(gameVersion) + if not GameVersion.VERSIONS[gameVersion] then return nil end + if GameVersion.generation(gameVersion) ~= 2 then return nil end + return GameVersion.info(gameVersion).displayName +end -- importSav(bytes, version, gameVersion) -> saveTable, err -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data @@ -245,7 +247,7 @@ function SaveConvert.importSav(bytes, version, gameVersion) if type(bytes) ~= "string" then return nil, "expected raw save bytes as a string" end - local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion] + local gen2Name = gen2CartName(gameVersion) if gen2Name then return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." end @@ -281,7 +283,7 @@ function SaveConvert.exportSav(saveTable, gameVersion) if type(saveTable) ~= "table" then return nil, "expected a save table" end - local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion] + local gen2Name = gen2CartName(gameVersion) if gen2Name then return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." end diff --git a/src/script/gen2/Opcodes.lua b/src/script/gen2/Opcodes.lua index 80c34eb2..0972ecc3 100644 --- a/src/script/gen2/Opcodes.lua +++ b/src/script/gen2/Opcodes.lua @@ -173,6 +173,113 @@ local Opcodes = { [0xa1] = { name = "warpfacing", size = 5 }, } +-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2 +Opcodes.NUM_EVENT_COMMANDS = 0xa2 + +-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so +-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it. +local CRYSTAL = {} +for byte = 0x00, 0x51 do + local row = Opcodes[byte] + CRYSTAL[byte] = { name = row.name, size = row.size } +end + +-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against +-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237. +CRYSTAL[0x52] = { name = "farjumptext", size = 3 } +CRYSTAL[0x53] = { name = "jumptext", size = 2 } +CRYSTAL[0x54] = { name = "waitbutton", size = 0 } +CRYSTAL[0x55] = { name = "promptbutton", size = 0 } +CRYSTAL[0x56] = { name = "pokepic", size = 1 } +CRYSTAL[0x57] = { name = "closepokepic", size = 0 } +CRYSTAL[0x58] = { name = "_2dmenu", size = 0 } +CRYSTAL[0x59] = { name = "verticalmenu", size = 0 } +CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 } +CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 } +CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 } +CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 } +CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 } +CRYSTAL[0x5f] = { name = "startbattle", size = 0 } +CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 } +CRYSTAL[0x61] = { name = "catchtutorial", size = 1 } +CRYSTAL[0x62] = { name = "trainertext", size = 1 } +CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 } +CRYSTAL[0x64] = { name = "winlosstext", size = 4 } +CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 } +CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 } +CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 } +CRYSTAL[0x68] = { name = "setlasttalked", size = 1 } +CRYSTAL[0x69] = { name = "applymovement", size = 3 } +CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 } +CRYSTAL[0x6b] = { name = "faceplayer", size = 0 } +CRYSTAL[0x6c] = { name = "faceobject", size = 2 } +CRYSTAL[0x6d] = { name = "variablesprite", size = 2 } +CRYSTAL[0x6e] = { name = "disappear", size = 1 } +CRYSTAL[0x6f] = { name = "appear", size = 1 } +CRYSTAL[0x70] = { name = "follow", size = 2 } +CRYSTAL[0x71] = { name = "stopfollow", size = 0 } +CRYSTAL[0x72] = { name = "moveobject", size = 3 } +CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 } +CRYSTAL[0x74] = { name = "loademote", size = 1 } +CRYSTAL[0x75] = { name = "showemote", size = 3 } +CRYSTAL[0x76] = { name = "turnobject", size = 2 } +CRYSTAL[0x77] = { name = "follownotexact", size = 2 } +CRYSTAL[0x78] = { name = "earthquake", size = 1 } +CRYSTAL[0x79] = { name = "changemapblocks", size = 3 } +CRYSTAL[0x7a] = { name = "changeblock", size = 3 } +CRYSTAL[0x7b] = { name = "reloadmap", size = 0 } +CRYSTAL[0x7c] = { name = "refreshmap", size = 0 } +CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 } +CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 } +CRYSTAL[0x7f] = { name = "playmusic", size = 2 } +CRYSTAL[0x80] = { name = "encountermusic", size = 0 } +CRYSTAL[0x81] = { name = "musicfadeout", size = 3 } +CRYSTAL[0x82] = { name = "playmapmusic", size = 0 } +CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 } +CRYSTAL[0x84] = { name = "cry", size = 2 } +CRYSTAL[0x85] = { name = "playsound", size = 2 } +CRYSTAL[0x86] = { name = "waitsfx", size = 0 } +CRYSTAL[0x87] = { name = "warpsound", size = 0 } +CRYSTAL[0x88] = { name = "specialsound", size = 0 } +CRYSTAL[0x89] = { name = "autoinput", size = 3 } +CRYSTAL[0x8a] = { name = "newloadmap", size = 1 } +CRYSTAL[0x8b] = { name = "pause", size = 1 } +CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 } +CRYSTAL[0x8d] = { name = "sdefer", size = 2 } +CRYSTAL[0x8e] = { name = "warpcheck", size = 0 } +CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 } +CRYSTAL[0x90] = { name = "endcallback", size = 0 } +CRYSTAL[0x91] = { name = "end", size = 0 } +CRYSTAL[0x92] = { name = "reloadend", size = 1 } +CRYSTAL[0x93] = { name = "endall", size = 0 } +CRYSTAL[0x94] = { name = "pokemart", size = 3 } +CRYSTAL[0x95] = { name = "elevator", size = 2 } +CRYSTAL[0x96] = { name = "trade", size = 1 } +CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 } +CRYSTAL[0x98] = { name = "phonecall", size = 2 } +CRYSTAL[0x99] = { name = "hangup", size = 0 } +CRYSTAL[0x9a] = { name = "describedecoration", size = 1 } +CRYSTAL[0x9b] = { name = "fruittree", size = 1 } +CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 } +CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 } +CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 } +CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 } +-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare +-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662). +CRYSTAL[0xa0] = { name = "swarm", size = 3 } +CRYSTAL[0xa1] = { name = "halloffame", size = 0 } +CRYSTAL[0xa2] = { name = "credits", size = 0 } +CRYSTAL[0xa3] = { name = "warpfacing", size = 5 } +CRYSTAL[0xa4] = { name = "battletowertext", size = 1 } +CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 } +CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 } +CRYSTAL[0xa7] = { name = "getname", size = 3 } +CRYSTAL[0xa8] = { name = "wait", size = 1 } +CRYSTAL[0xa9] = { name = "checksave", size = 0 } + +-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa +CRYSTAL.NUM_EVENT_COMMANDS = 0xaa + -- Commands that end the current linear path (jumps transfer control). -- -- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does @@ -185,9 +292,13 @@ local Opcodes = { -- these four regardless. `catchtutorial` is deliberately NOT here: it ends on -- `jp Script_reloadmap` and the script really does continue afterwards, the -- same way `reloadmap` itself does. +-- +-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext` +-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on +-- `jp ScriptJump` into JumpTextScript. Opcodes.TERMINATORS = { sjump = true, farsjump = true, memjump = true, jumpstd = true, - jumptext = true, jumptextfaceplayer = true, + jumptext = true, farjumptext = true, jumptextfaceplayer = true, stopandsjump = true, ["end"] = true, endall = true, endcallback = true, reloadend = true, fruittree = true, describedecoration = true, @@ -198,10 +309,12 @@ Opcodes.TERMINATORS = { -- behind it. Every row above is keyed by the opcode byte the cart carries, and -- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte -- that is not in that table breaks the pointer walk into an `unknown` row. Give --- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start --- with it would decode as a mod call instead of ending the walk, so the free --- space stays free and the extension op is reachable only by NAME -- which is to --- say, only from a row a mod wrote, never from one the extractor did. +-- a mod verb one of the free bytes and ROM data that happens to start with it +-- would decode as a mod call instead of ending the walk, so the free space stays +-- free and the extension op is reachable only by NAME -- which is to say, only +-- from a row a mod wrote, never from one the extractor did. The free space is +-- per dialect and the intersection is what matters: Gold leaves $a2..$ff +-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068). -- -- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the -- row shapes and the verb table is documented there. @@ -211,4 +324,15 @@ function Opcodes.key(bank, address) return string.format("%02x:%04x", bank, address) end +CRYSTAL.TERMINATORS = Opcodes.TERMINATORS +CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND +CRYSTAL.key = Opcodes.key + +-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540); +-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541). +function Opcodes.forEdition(edition) + if edition == "crystal" then return CRYSTAL end + return Opcodes +end + return Opcodes diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 6a29da98..b45b4065 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1,7 +1,7 @@ -- The `special` command's handlers (data/events/special_pointers.asm). -- -- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of --- code: the VM is one interpreter with a shared control flow, and this is 112 +-- code: the VM is one interpreter with a shared control flow, and this is 169 -- INDEPENDENT routines that happen to share a dispatch table. Growing them -- inside runList's else-chain would have buried the interpreter. -- @@ -9,9 +9,9 @@ -- which the extractor turns into constants.specialOrder, and Vm:specialName -- resolves the one into the other; keying on the label rather than on the -- number means a repointed table cannot silently call the wrong routine, and --- it means a test can assert the mapping against the cache. This cache's --- order has 112 rows -- the asm file's 113 `add_special` matches include the --- MACRO line itself. +-- it means a test can assert the mapping against the cache. A Gold cache's +-- order has 112 rows and a Crystal one 169 -- the asm files' `add_special` +-- match counts are one higher each, because they include the MACRO line. -- -- Three kinds of entry live here: -- @@ -52,6 +52,7 @@ -- it, plus the fruit trees and the daily rollover Kurt waits on. local Apricorns = require("src.core.gen2.Apricorns") local BugContest = require("src.core.gen2.BugContest") +local GameVersion = require("src.core.GameVersion") local Happiness = require("src.core.gen2.Happiness") local Phone = require("src.core.gen2.Phone") local Pokerus = require("src.core.gen2.Pokerus") @@ -175,6 +176,20 @@ local function selectMon(vm, prompt) return picked.index, picked.mon end +Specials.shared = { + TRUE = TRUE, + FALSE = FALSE, + block = Specials.block, + hooks = hooks, + party = party, + save = save, + data = data, + answer = answer, + nameMon = nameMon, + selectMon = selectMon, + showRawHeld = showRawHeld, +} + -------------------------------------------------------------------------- -- Magikarp lengths (engine/events/magikarp.asm) -------------------------------------------------------------------------- @@ -1058,7 +1073,7 @@ end -- -- is drawn on the cartridge itself, and only the A press farcalls -- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the -- A press nowhere, which is what a cartridge with nothing in its link port --- does; PrintDiploma next door has no viewer half at all and stays a stub. +-- does; PrintDiploma next door shows its page and prints nothing either. -- -- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the -- special returns before it draws anything. The routine never writes @@ -1117,6 +1132,10 @@ H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end +-- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same +-- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand. +H.BattleTowerFade = function(vm) fade(vm, "inWhite") end + -- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is -- blanked to the background colour under a fade that is already down. The -- port fades with a flat sheet, so the sheet IS the cleared screen. @@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(vm) if h.reloadSprites then h.reloadSprites(false) end end +-- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp +-- GetSGBLayout`: the map's own palette layout, reapplied and nothing else. +H.LoadMapPalettes = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(true) end +end + +-- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its +-- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm. +H.RefreshSprites = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(false) end +end + -- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState -- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes -- getting on and off a Lapras a one-byte change rather than an animation. @@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(vm) if h.updatePlayerSprite then h.updatePlayerSprite() end end +-- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515: +-- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette. +H.SetPlayerPalette = function(vm) + local value = (vm.scriptVar or 0) % 0x100 + if value < 0x80 then return end + vm.playerPalette = math.floor(value / 0x10) % 8 + local h = hooks(vm) + if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end +end + -- ---- 58-62 sound and the water -------------------------------------------- -- WaitSFX: hold until the sound effect that is playing finishes. The VM has @@ -1445,6 +1488,22 @@ local function trailingDigitsShared(a, b) end Specials.trailingDigitsShared = trailingDigitsShared +-- pokegold/constants/pokemon_data_constants.asm:122-123 +local NUM_BOXES, NUM_BOXES_JP = 14, 9 + +-- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked +-- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES. +local function luckyNumberBoxOrder(record) + local current = math.floor(tonumber(record and record.currentBox) or 1) + local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP + local order = { current } + for index = 1, last do + if index ~= current then order[#order + 1] = index end + end + return order +end +Specials.luckyNumberBoxOrder = luckyNumberBoxOrder + local function luckyPrizeFor(shared) if shared >= 5 then return 1 end if shared >= 3 then return 2 end @@ -1508,8 +1567,9 @@ H.CheckForLuckyNumberWinners = function(vm) end end for _, mon in ipairs(party(vm)) do consider(mon, false) end - for _, box in pairs((record and record.boxes) or {}) do - for _, mon in ipairs(box or {}) do consider(mon, true) end + local boxes = (record and record.boxes) or {} + for _, index in ipairs(luckyNumberBoxOrder(record)) do + for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end end answer(vm, best) if bestMon then @@ -1586,9 +1646,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20 -- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no -- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer -- `xor a`, which is the FALSE `.Cancel` waits on. +-- +-- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a +-- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a` +-- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what +-- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL, +-- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the +-- count written here is the one apricorn Apricorns.takeApricorn tosses. H.SelectApricornForKurt = function(vm) local h = hooks(vm) local record = save(vm) + if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end local list = Apricorns.bagList(record and record.inventory) if list.empty then return answer(vm, FALSE) end @@ -1612,7 +1680,10 @@ H.SelectApricornForKurt = function(vm) local item = h.itemIndex and h.itemIndex(apricorn) if not item or item == 0 then return answer(vm, FALSE) end answer(vm, item) - Apricorns.takeApricorn(record, apricorn) + if Apricorns.takeApricorn(record, apricorn) + and h.setKurtApricornQuantity then + h.setKurtApricornQuantity(1) + end end -- ---- 88-89 the first party slot ------------------------------------------- @@ -1917,7 +1988,7 @@ local OAK_RATINGS = { -- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs -- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and -- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's - -- `special PrintDiploma` (also stubbed: "printer: no Game Boy Printer"). + -- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua). -- A finished #DEX here just means every rating after this one is this -- same line. { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( @@ -2027,7 +2098,7 @@ Specials.ROAMERS = Roamers.SPECIES H.InitRoamMons = function(vm) local record = save(vm) if not record then return end - Roamers.init(record, { force = true }) + Roamers.init(record, { force = true, data = data(vm) }) end -- ---- the #DEX-completion diploma ------------------------------------------- @@ -2328,7 +2399,7 @@ end -- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the -- serial port to a physical Game Boy Printer. There is no peripheral for it -- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and --- PrintDiploma stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz, +-- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz, -- .cancel` is -- hardwired to the nz arm below: the portrait shows, then the print always -- comes back as though the printer errored, which is the honest answer for @@ -2370,11 +2441,137 @@ H.PhotoStudio = function(vm) vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) end +-- ---- 21 the quick save ----------------------------------------------------- +-- +-- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is +-- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and +-- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame +-- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary +-- write, so the FALSE arm is a refusal at the overwrite prompt -- which is +-- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on, +-- and what the four PokeCenter2F cable rows share. +-- +-- Two differences from the cart, neither observable in the answer. +-- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before +-- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the +-- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than +-- after the saved page has typed. + +-- data/text/common_2.asm:1279-1299. +local SAVE_TEXT = { + already = Strings.source( + "There is already a\nsave file. Is it\vOK to overwrite?"), + another = Strings.source( + "There is another\nsave file. Is it\vOK to overwrite?"), + saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."), + saved = Strings.source("%s saved\nthe game."), +} + +-- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the +-- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the +-- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s +-- because the world does not tick while a box owns the stack (Vm:showRaw). +local SAVING_HOLD = 16 + 32 +local SAVED_HOLD = 30 + 30 + +H.TryQuickSave = function(vm) + local h = hooks(vm) + -- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then + -- CompareLoadedAndSavedPlayerID (:212) picking which question is asked. + local exists, sameId = false, false + if h.saveFileState then exists, sameId = h.saveFileState() end + if exists then + showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another)) + if not coroutine.yield({ kind = "yesorno" }) then + return answer(vm, FALSE) + end + end + vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD) + -- _SaveGameData (:273). A veto from the save.write mod hook is the one way + -- this port can refuse a write the cart always completes, and a refusal is + -- the same FALSE the overwrite prompt's NO gives. + if not (h.writeSave and h.writeSave() ~= false) then + return answer(vm, FALSE) + end + if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end + local record = save(vm) + local name = (record and record.player and record.player.name) or "" + vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD) + answer(vm, TRUE) +end + -- ---- 111 the dummy -------------------------------------------------------- -- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a -- handler rather than to the unimplemented ledger. H.UnusedDummySpecial = function() end +-- ---- 109-165 the Crystal rows --------------------------------------------- +-- data/events/special_pointers.asm:124-181, the rows only Crystal has. + +-- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes, +-- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`. +local function ownsMonAnywhere(vm, wanted) + local h = hooks(vm) + local list = party(vm) + if #list == 0 then return false end + local record = save(vm) + local player = record and record.player + local function owns(mon) + if not mon then return false end + if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end + if player and player.id and mon.otId and mon.otId ~= player.id then + return false + end + if player and player.name and mon.ot and mon.ot ~= player.name then + return false + end + return true + end + for _, mon in ipairs(list) do + if owns(mon) then return true end + end + for _, box in pairs((record and record.boxes) or {}) do + for _, mon in ipairs(box or {}) do + if owns(mon) then return true end + end + end + return false +end + +-- ../pokecrystal/engine/pokemon/search_owned.asm:31 +H.MonCheck = function(vm) + answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE) +end + +-- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright +-- splash, which is Game2:softReset rather than Game2:returnToTitle. +H.Reset = function(vm) + local h = hooks(vm) + if h.softReset then h.softReset() end +end + +-- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind +-- it as dead code, so a no-op is the whole routine. +H.StubbedTrainerRankings_Healings = function() end + +-- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which +-- is what sends every Pokecenter 2F mobile branch down its cable arm. +H.CheckMobileAdapterStatusSpecial = function(vm) + answer(vm, FALSE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`. +H.UnusedBattleTowerDummySpecial1 = function() end +H.UnusedBattleTowerDummySpecial2 = function() end + +-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown: +-- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer, +-- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK. +H.SampleKenjiBreakCountdown = function(vm) + local h = hooks(vm) + if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end +end + -------------------------------------------------------------------------- -- The deliberate stubs -------------------------------------------------------------------------- @@ -2394,7 +2591,6 @@ local STUB_ROWS = { { "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" }, { "WaitForLinkedFriend", 0, "link cable: nobody ever connects" }, { "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" }, - { "TryQuickSave", 0, "link cable: the cable club's own save is not ported" }, { "CheckBothSelectedSameRoom", 0, "link cable: no second player" }, { "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" }, { "CloseLink", nil, "link cable: nothing to close" }, @@ -2415,45 +2611,149 @@ local STUB_ROWS = { { "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" }, { "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" }, { "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" }, - -- The Game Boy Printer. A second peripheral again, and two of the three - -- specials that want it are not in this table: PhotoStudio ports the - -- conversation and the portrait screen (H.PhotoStudio above) and - -- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the - -- print itself stubbed inside each. PrintDiploma is the one that is - -- nothing BUT the print -- the diploma's own page is `special Diploma`, a - -- separate row -- so it is all that is left here. + -- The Game Boy Printer. A second peripheral again, and none of the three + -- specials that want it is stubbed any more: PhotoStudio ports the + -- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter + -- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens + -- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print + -- itself going nowhere inside each. The row below is superseded by that + -- handler and survives only as the reason. { "PrintDiploma", nil, "printer: no Game Boy Printer" }, - -- Screens the port has not built. Each names what it needs; the value is - -- the arm a cancel takes, so the script backs out rather than proceeding - -- through a transaction that never happened. + -- OverworldTownMap's row is superseded by H.OverworldTownMap + -- (specials/crystal_extras.lua) and survives only as the reason. + -- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both + -- carry `add_special UnusedMemoryGame ; unused`, and no script names it. { "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" }, - { "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" }, + { "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" }, + -- data/events/special_pointers.asm:124-181, the rows only Crystal has. + { "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" }, + { "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" }, + { "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" }, + { "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" }, + { "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" }, + { "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" }, + { "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" }, + { "Function1700ba", nil, "Battle Tower: mobile challenge setup" }, + { "Function170114", nil, "Battle Tower: mobile challenge setup" }, + { "Function1704e1", nil, "Battle Tower: mobile challenge setup" }, + { "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" }, + { "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" }, + { "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" }, + { "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" }, + { "Function101225", 0, "Mobile System GB: mobile trade room teardown" }, + { "Function101231", 0, "Mobile System GB: mobile battle room teardown" }, + { "Function102142", nil, "Mobile System GB: mobile news feed" }, + { "Function103780", 0, "Mobile System GB: the mobile save never happens" }, + { "Function1037c2", 0, "Mobile System GB: no rematch on same settings" }, + { "Function1037eb", 0, "Mobile System GB: no battle time is left" }, + { "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" }, + { "Function10387b", nil, "Mobile System GB: adapter status readback" }, + { "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" }, + { "Function11ac3e", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b5e8", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b7e5", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b879", 0, "Mobile System GB: trade corner submenu" }, + { "Function11b920", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b93b", nil, "Mobile System GB: trade corner submenu" }, + { "Function11ba38", 0, "Mobile System GB: trade corner submenu" }, + { "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" }, + { "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" }, + { "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" }, + { "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" }, + { "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" }, + { "DisplayUnownWords", nil, "needs the Unown wall word box" }, + { "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" }, + { "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" }, + { "PokeSeer", nil, "needs the Seer's caught-data page" }, + { "BeastsCheck", 0, "the three beasts cannot all be owned this early" }, + { "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" }, + { "BuenaPrize", nil, "Buena's prize counter is not ported" }, + { "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" }, + { "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" }, + { "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" }, + { "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" }, + { "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" }, } Specials.HANDLERS = H + +-- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one +-- module per owner under src/script/gen2/specials/, merged into HANDLERS here. +Specials.MODULES = { + "crystal_story", + "battle_tower", + "crystal_extras", + "unown_words", +} + +Specials.HANDLER_SOURCE = {} +for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end + Specials.STUBS = {} Specials.STUB_REASONS = {} - -for _, row in ipairs(STUB_ROWS) do - local name, value, reason = row[1], row[2], row[3] - Specials.STUB_REASONS[name] = reason - Specials.STUBS[name] = function(vm) - if value ~= nil then vm.scriptVar = value end - end -end +Specials.SUPERSEDED_STUBS = {} -- The dispatch table Vm.SPECIALS is. Built rather than written out so the two -- sets cannot drift, and so a name that ends up in both is a hard error here -- rather than a silent shadow at runtime. Specials.ALL = {} -for name, fn in pairs(Specials.HANDLERS) do - Specials.ALL[name] = fn -end -for name, fn in pairs(Specials.STUBS) do - if Specials.ALL[name] then - error("gen2 special '" .. name .. "' is both implemented and stubbed", 0) - end - Specials.ALL[name] = fn + +local function clear(t) + for key in pairs(t) do t[key] = nil end end +local function rebuild() + clear(Specials.STUBS) + clear(Specials.STUB_REASONS) + clear(Specials.SUPERSEDED_STUBS) + clear(Specials.ALL) + for _, row in ipairs(STUB_ROWS) do + local name, value, reason = row[1], row[2], row[3] + if H[name] then + if Specials.HANDLER_SOURCE[name] == "Specials.lua" then + error("gen2 special '" .. name .. "' is both implemented and stubbed", 0) + end + Specials.SUPERSEDED_STUBS[name] = reason + else + Specials.STUB_REASONS[name] = reason + Specials.STUBS[name] = function(vm) + if value ~= nil then vm.scriptVar = value end + end + end + end + for name, fn in pairs(H) do Specials.ALL[name] = fn end + for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end +end + +function Specials.merge(handlers, source) + if type(handlers) ~= "table" then + error("gen2 specials module '" .. tostring(source) .. "' returned " + .. type(handlers) .. ", expected a table of name -> function", 0) + end + for name, fn in pairs(handlers) do + if type(name) ~= "string" or type(fn) ~= "function" then + error("gen2 specials module '" .. tostring(source) + .. "' entry [" .. tostring(name) .. "] is not name -> function", 0) + end + local owner = Specials.HANDLER_SOURCE[name] + if owner then + error("gen2 special '" .. name .. "' is defined twice: " + .. owner .. " and " .. tostring(source), 0) + end + H[name] = fn + Specials.HANDLER_SOURCE[name] = source + end + rebuild() + return handlers +end + +package.loaded["src.script.gen2.Specials"] = Specials + +for _, name in ipairs(Specials.MODULES) do + Specials.merge(require("src.script.gen2.specials." .. name), + "specials/" .. name .. ".lua") +end + +rebuild() + return Specials diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 4fe546fa..6652153c 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999 local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b -- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff. local EMOTE_FROM_MEM = 0xff +-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff +local ITEM_FROM_MEM = 0xff +-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args. +local SWARM_DUNSPARCE = 0 +-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values. +local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7 +-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait +local WAIT_FRAMES_PER_UNIT = 6 -- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver. local GS_VERSION_GOLD = 0 -- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and @@ -204,7 +212,9 @@ local function runCmd(self, cmd, op) local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2)) if value ~= nil then self.scriptVar = value % 256 end end - elseif op == "jumptext" then + elseif op == "jumptext" or op == "farjumptext" then + -- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext: + -- Script_jumptext with a `dba` for the `dw`, same JumpTextScript. self:emitFace(false) self:showText(cmd.text) return "end" @@ -519,6 +529,36 @@ local function runCmd(self, cmd, op) -- town map draws them two rows deep). local name = self.getLandmarkNameFn and self.getLandmarkNameFn() if name then self:setStringBuffer(name) end + elseif op == "getlandmarkname" then + -- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id + -- comes off the script, then ConvertLandmarkToText and a buffer byte. + local id = cmd.landmark or arg1(cmd) or 0 + local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id) + if name then self:setStringBuffer(name) end + elseif op == "gettrainerclassname" then + -- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is + -- preset, so the one id byte ContinueToGetName reads is a trainer group. + if self.getTrainerClassNameFn then + local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0) + if name then self:setStringBuffer(name) end + end + elseif op == "getname" then + -- pokecrystal/engine/overworld/scripting.asm:1633-1641: a + -- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte. + local args = cmd.args or {} + local kind = cmd.kind or args[1] or 0 + local id = cmd.id or args[2] or 0 + local name + if kind == NAMED_MON and self.getMonNameFn then + name = self.getMonNameFn(id) + elseif kind == NAMED_ITEM and self.getItemNameFn then + name = self.getItemNameFn(id) + elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then + name = self.getTrainerClassNameFn(id) + elseif self.getNameFn then + name = self.getNameFn(kind, id) + end + if name then self:setStringBuffer(name) end elseif op == "getnum" then -- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte, -- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into @@ -599,9 +639,17 @@ local function runCmd(self, cmd, op) else self.scriptVar = POKEMAIL_REFUSED end - elseif op == "giveitem" or op == "verbosegiveitem" then + elseif op == "giveitem" or op == "verbosegiveitem" + or op == "verbosegiveitemvar" then local item = cmd.item or arg1(cmd) or 0 local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1 + if op == "verbosegiveitemvar" then + -- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM + -- takes the item from wScriptVar, and byte two is a VAR_* id. + if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end + local varId = cmd.var or (cmd.args and cmd.args[2]) or 0 + qty = self.readVarFn and self.readVarFn(varId) or 0 + end -- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is -- what the standalone `specialsound` inside GiveItemScript reads back: -- CheckItemPocket runs on wCurItem, not on anything the opcode carries. @@ -611,7 +659,7 @@ local function runCmd(self, cmd, op) ok = self.giveItemFn(item, qty) ~= false end self.scriptVar = ok and 1 or 0 - if op == "verbosegiveitem" then + if op == "verbosegiveitem" or op == "verbosegiveitemvar" then local name = self.getItemNameFn and self.getItemNameFn(item) or "?" self:setStringBuffer(name) -- GiveItemScript (engine/overworld/scripting.asm:441-449), command for @@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op) -- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag -- is what makes the swarm expire, so a port that only stores the map -- leaves the Dunsparce call permanently live. + -- + -- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte + -- and specials.asm:290-298 picks the index pair off it, so three operand + -- bytes means the map_id has moved along one. local args = cmd.args or {} - local group = cmd.group or args[1] - local mapNum = cmd.map or args[2] - if self.setSwarmFn then self.setSwarmFn(group, mapNum) end + local kind, group, mapNum + if #args >= 3 then + kind, group, mapNum = args[1], args[2], args[3] + else + kind = SWARM_DUNSPARCE + group = cmd.group or args[1] + mapNum = cmd.map or args[2] + end + if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end elseif op == "reloadmapafterbattle" or op == "reloadmap" or op == "refreshmap" then -- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult @@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op) coroutine.yield({ kind = "credits" }) end return "end" + -- ---- Crystal-only verbs ------------------------------------------------ + elseif op == "wait" then + -- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX + -- frames of DelayFrames per operand unit, not Script_pause's two. + self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT) + elseif op == "checksave" then + -- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's + -- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20). + local ok = true + if self.checkSaveFn then ok = self.checkSaveFn() and true or false end + self.scriptVar = ok and 1 or 0 + elseif op == "battletowertext" then + -- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText. + -- Unported: the table consumes the operand and the verb warns once. + self:noteUnknownOp(op) -- ---- commands with no engine behind them yet --------------------------- elseif op == "deactivatefacing" then -- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the @@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks) givePokeMailFn = hooks.givePokeMail, checkPokeMailFn = hooks.checkPokeMail, getLandmarkNameFn = hooks.getLandmarkName, + -- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353. + -- Absent on a Gold boot, where no opcode reaches them. + getTrainerClassNameFn = hooks.getTrainerClassName, + getNameFn = hooks.getName, + checkSaveFn = hooks.checkSave, -- loadmenu stashes a header for the verticalmenu / _2dmenu that follows; -- openMenu is the blocking half, modelled on yesorno. openMenuFn = hooks.openMenu, diff --git a/src/script/gen2/specials/battle_tower.lua b/src/script/gen2/specials/battle_tower.lua new file mode 100644 index 00000000..c2b86b9a --- /dev/null +++ b/src/script/gen2/specials/battle_tower.lua @@ -0,0 +1,397 @@ +-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and +-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel. + +local Specials = require("src.script.gen2.Specials") +local S = Specials.shared + +local Bag = require("src.inventory.Bag") +local BattleTower = require("src.core.gen2.BattleTower") +local RomText = require("src.core.RomText") +local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") + +local M = {} + +local TRUE, FALSE = S.TRUE, S.FALSE +local A = BattleTower.ACTIONS + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196 +-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the +-- room script `readmem`s, so it is zeroed in the VM's own memory store. +local function initChallengeRam(vm) + vm.btBattleEnded = 0 + vm.btBeaten = 0 + if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels +-- data/generated/rom_text.lua carries. +local RULE_FALLBACKS = { + _ExcuseMeYoureNotReadyText = + Strings.source("Excuse me.\nYou're not ready."), + _OnlyThreeMonMayBeEnteredText = + Strings.source("Only three POKéMON\nmay be entered."), + _TheMonMustAllBeDifferentKindsText = + Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."), + _TheMonMustNotHoldTheSameItemsText = + Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."), + _YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"), + _BattleTowerReturnWhenReadyText = + Strings.source("Please return when\nyou're ready."), +} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171 +-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded +-- save here, so CompareLoadedAndSavedPlayerID can only match. +local function saveFileIsYours(vm) + local record = S.save(vm) + if not (record and record.version) then return FALSE end + return Save.exists(record.version) and TRUE or FALSE +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and +-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile +-- System GB adapter. A cartridge that never linked reads them as zero, and +-- these are the values that zero produces. +local MOBILE_ARMS = { + [A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" }, + [A.ACTION_06] = { why = "clears the mobile challenge bytes" }, + [A.ACTION_0C] = { why = "stamps the mobile challenge day" }, + [A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" }, + [A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" }, + [A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" }, + [A.ACTION_16] = { why = "stamps the mobile news day" }, + [A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" }, + [A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" }, + [A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" }, +} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953 +-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches. +local UNHOOKED_ARMS = { + [A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks", +} + +local ACTIONS = {} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990 +ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record) + local yours = saveFileIsYours(vm) + S.answer(vm, yours) + if yours == 0 then return end + S.answer(vm, BattleTower.saveFileFlag(record, + BattleTower.SAVEFILE_EXPLANATION)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008 +ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record) + BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999 +ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower) + S.answer(vm, tower.challenge) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012 +ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022 +ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141 +ACTIONS[A.SAVELEVELGROUP] = function(vm, tower) + tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155 +ACTIONS[A.LOADLEVELGROUP] = function(vm, tower) + vm.btLevelGroup = tower.levelGroup +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171 +ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm) + S.answer(vm, saveFileIsYours(vm)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the +-- pending fade is dropped and the volume goes back to full, which at both +-- call sites follows a `musicfadeout MUSIC_NONE`. +ACTIONS[A.ACTION_0A] = function(vm) + local h = S.hooks(vm) + if h.stopMusic then h.stopMusic() end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185: +-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered, +-- and the Goldenrod scene gates itself on its own event flag afterwards. +ACTIONS[A.GSBALL] = function(vm, _tower, record) + local crystal = record and Save.crystalState(record) + local flag = crystal and crystal.gsBall + S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312 +-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg. +local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142" + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the +-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP. +ACTIONS[A.EGGTICKET] = function(vm, _tower, record) + S.answer(vm, FALSE) + local held = record and record.inventory and record.inventory.EGG_TICKET + if not held or held <= 0 then return end + for _, mon in ipairs(S.party(vm)) do + if mon.isEgg and mon.otName == MYSTERY_OT then + mon.otName = "" + Bag.remove(record, "EGG_TICKET", 1) + return S.answer(vm, TRUE) + end + end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461 +ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end +ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469 +ACTIONS[A.ACTION_13] = function(vm, tower) + S.answer(vm, tower.reentry and TRUE or FALSE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 +ACTIONS[A.ACTION_14] = function(vm, _tower, record) + local yours = saveFileIsYours(vm) + S.answer(vm, yours) + if yours == 0 then return end + S.answer(vm, BattleTower.saveFileFlag(record, + BattleTower.SAVEFILE_REGISTERED)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492 +ACTIONS[A.ACTION_15] = function(_vm, _tower, record) + BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904 +ACTIONS[A.RESETDATA] = function(_vm, _tower, record) + BattleTower.resetTrainers(record) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933 +ACTIONS[A.GIVEREWARD] = function(vm, tower, record) + local h = S.hooks(vm) + local reward = tower.reward or BattleTower.FALLBACK_REWARD + local data = S.data(vm) + local fits = false + if record then + record.inventory = record.inventory or {} + fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"), + Bag.capacity(data, "ITEM"), record.inventory[reward]) + end + if not fits then reward = BattleTower.FALLBACK_REWARD end + S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949 +ACTIONS[A.ACTION_1C] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE) +end +ACTIONS[A.ACTION_1D] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976. +-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1 +-- byte `maskbits` leaves in a. +ACTIONS[A.CHOOSEREWARD] = function(vm, tower) + local data = S.data(vm) + local order = data and data.gen2Constants and data.gen2Constants.itemOrder + tower.reward = BattleTower.rollReward(order, Specials.random) + or BattleTower.FALLBACK_REWARD +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the +-- jumptable `special BattleTowerAction` dispatches on wScriptVar. +M.BattleTowerAction = function(vm) + local id = math.floor(tonumber(vm.scriptVar) or 0) + -- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds. + local record = S.save(vm) or {} + local tower = BattleTower.state(record) + local run = ACTIONS[id] + if run then return run(vm, tower, record) end + local mobile = MOBILE_ARMS[id] + if mobile then + if mobile.value ~= nil then S.answer(vm, mobile.value) end + return + end + if UNHOOKED_ARMS[id] then return end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the +-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is +-- what the script's `ifnotequal FALSE` reads as "stop here". +M.CheckForBattleTowerRules = function(vm) + local lines, failed = BattleTower.checkRules(S.party(vm)) + local data = S.data(vm) + -- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2 + vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT) + for _, label in ipairs(lines) do + vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label)) + end + S.answer(vm, failed and TRUE or FALSE) +end + +-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495. +-- wScriptVar comes in TRUE for the English rows and leaves holding the row +-- number, or 4 for a B press. +local CHALLENGE_MENU_ROWS = { + Strings.source("Challenge"), + Strings.source("Explanation"), + Strings.source("Cancel"), +} +local CHALLENGE_MENU_CANCEL = 4 + +-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0, +-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP. +local CHALLENGE_MENU_FLAGS = 0x80 + 0x20 + +M.Menu_ChallengeExplanationCancel = function(vm) + local h = S.hooks(vm) + local choice = Specials.block(vm, function(done) + if not h.scriptMenu then return done(0) end + h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0, + right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 }, + done) + end) + choice = math.floor(tonumber(choice) or 0) + if choice < 1 or choice > #CHALLENGE_MENU_ROWS then + return S.answer(vm, CHALLENGE_MENU_CANCEL) + end + S.answer(vm, choice) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose +-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the +-- three mons and the two SRAM tables that stop either repeating. wScriptVar +-- comes in holding the object the opponent walks in as and is NOT written. +M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm) + local record = S.save(vm) + if not record then return end + -- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not + -- the save; only the sBTTrainers slot and the two previous teams persist. + local opponent = BattleTower.drawOpponent( + S.data(vm), record, Specials.random) + vm.btOpponent = opponent + if not opponent then return end + -- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object + -- wScriptVar names, and GetUsedSprite loads the sheet. + local h = S.hooks(vm) + if h.setObjectSprite and opponent.sprite then + h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite) + end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the +-- RunBattleTowerTrainer arm its jumptable runs at :214-259. +-- +-- The order there is: SET battle mode forced on, wInBattleTowerBattle set, +-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and +-- arms the challenge), StartBattle, HealParty again, and wBattleResult into +-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into +-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to +-- decide whether all seven are done -- and leaves `count + 1` as the digit +-- Text_NextUpOpponentNo prints. +M.BattleTowerBattle = function(vm) + vm.btBattleEnded = 0 + local record = S.save(vm) + local data = S.data(vm) + local h = S.hooks(vm) + local opponent = vm.btOpponent + if not opponent and record then + opponent = BattleTower.drawOpponent(data, record, Specials.random) + vm.btOpponent = opponent + end + -- A cache with no `battleTower` block on trainers.lua has nobody to send + -- out. LOSE is the room script's own back-out arm + -- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge + -- ends rather than looping on an opponent that never appears. + if not (opponent and record) then + vm.btBattleEnded = 1 + return S.answer(vm, 1) + end + -- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570) + local streak = BattleTower.beginBattle(record) + -- :228 farcall HealParty, before the send-out. + if h.healParty then h.healParty() end + local classes = data and data.trainers and data.trainers.classes + local class = classes and classes[opponent.classId] + local className = (class and class.name) or opponent.classId + local party = BattleTower.battleParty(data, opponent.rows) + local outcome = Specials.block(vm, function(done) + if not h.startTowerBattle then return done("lose") end + local started = h.startTowerBattle({ + class = opponent.class, + classId = opponent.classId, + className = className, + -- PlaceEnemysName prints the class then the trainer's own name, the + -- same pair World:startScriptedBattle builds for an overworld trainer. + name = className and (className .. " " .. opponent.name) or opponent.name, + trainerName = opponent.name, + party = party, + -- wOtherTrainerClass is a real class here, so the AI weights, the two + -- item slots and the payout all come off its own attributes row. + attributes = class and class.attributes, + baseMoney = class and class.baseMoney, + items = class and class.items, + }, done) + if not started then done("lose") end + end) + -- :235 farcall HealParty, on the way out whichever way it went. + if h.healParty then h.healParty() end + -- :236-237 wBattleResult: WIN is 0. + local won = outcome ~= "lose" + S.answer(vm, won and 0 or 1) + if won and vm.mem then + -- :240-250 + vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256 + vm:setStringBuffer(tostring(streak + 1)) + end + -- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle. + vm.btBattleEnded = 1 + vm.btOpponent = nil +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is +-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu +-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a +-- chosen room and $a for the cancel the desk loops back on. +M.BattleTowerRoomMenu = function(vm) + initChallengeRam(vm) + local h = S.hooks(vm) + local record = S.save(vm) + local result = Specials.block(vm, function(done) + if not h.pushScreen then return done(nil) end + local ok = h.pushScreen("Gen2BattleTowerMenu", { + save = record, + party = S.party(vm), + rows = BattleTower.levelGroupRows(record), + monName = h.monName, + onDone = done, + }) + if not ok then done(nil) end + end) + local group = math.floor(tonumber(result) or 0) + if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then + -- ../pokecrystal/mobile/mobile_46.asm:4609-4610 + return S.answer(vm, 0x0a) + end + vm.btLevelGroup = group + S.answer(vm, 0) +end + +return M diff --git a/src/script/gen2/specials/crystal_extras.lua b/src/script/gen2/specials/crystal_extras.lua new file mode 100644 index 00000000..72a08354 --- /dev/null +++ b/src/script/gen2/specials/crystal_extras.lua @@ -0,0 +1,508 @@ +-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer, +-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword, +-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share: +-- :57 OverworldTownMap and :126 PrintDiploma. + +local Bag = require("src.inventory.Bag") +local BugContest = require("src.core.gen2.BugContest") +local Mon = require("src.battle.gen2.Mon") +local Nests = require("src.core.gen2.Nests") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Strings = require("src.core.Strings") + +local S = Specials.shared + +local M = {} + +-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword. +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 + +-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP. +local BLUE_CARD_POINT_CAP = 30 + +local function hooks(vm) + return (vm and vm.specials) or {} +end + +-------------------------------------------------------------------------- +-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1 +-------------------------------------------------------------------------- + +-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps +-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves. +local function tutorMove(vm, index) + local d = S.data(vm) + local list = d and d.pokemon and d.pokemon.tutorMoves + if type(list) ~= "table" then return nil end + if index ~= 1 and index ~= 2 then index = 3 end + return list[index] +end + +M.MoveTutor = function(vm) + local h = hooks(vm) + local moveId = tutorMove(vm, vm.scriptVar or 0) + -- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is + -- maps/GoldenrodCity.asm:72 .Incompatible. + if not (moveId and h.pushScreen) then + vm.scriptVar = 255 + return + end + local d = S.data(vm) + local moveDef = d and d.moves and d.moves[moveId] + local learned = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2MoveTutor", { + move = moveId, + moveName = (moveDef and moveDef.name) or moveId, + onDone = function(taught) done(taught and true or false) end, + }) + if not ok then done(false) end + end) + -- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm. + vm.scriptVar = learned and 0 or 255 +end + +-------------------------------------------------------------------------- +-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1 +-------------------------------------------------------------------------- + +-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_* +-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the +-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds. +local BUENA_PASSWORDS = { + { kind = "mon", points = 10, + words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } }, + { kind = "item", points = 12, + words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } }, + { kind = "item", points = 12, + words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } }, + { kind = "item", points = 12, + words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } }, + { kind = "mon", points = 10, + words = { "PIKACHU", "RATTATA", "GEODUDE" } }, + { kind = "mon", points = 10, + words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } }, + { kind = "string", points = 16, + words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"), + Strings.source("AZALEA TOWN") } }, + { kind = "string", points = 6, + words = { Strings.source("FLYING"), Strings.source("BUG"), + Strings.source("GRASS") } }, + { kind = "move", points = 12, + words = { "TACKLE", "GROWL", "MUD_SLAP" } }, + { kind = "item", points = 12, + words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } }, + { kind = "string", points = 13, + words = { Strings.source("#MON Talk"), Strings.source("#MON Music"), + Strings.source("Lucky Channel") } }, +} + +-- ../pokecrystal/constants/radio_constants.asm:120-121. +local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS +local NUM_PASSWORDS_PER_CATEGORY = 3 + +-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534). +local function passwordWord(vm, group, index) + local row = BUENA_PASSWORDS[group + 1] + if not row then return "?" end + local word = row.words[index + 1] + if not word then return "?" end + if row.kind == "string" then return Strings(word) end + local d = S.data(vm) + if row.kind == "mon" then + local def = d and d.pokemon and d.pokemon[word] + return (def and def.name) or word + end + if row.kind == "item" then + local def = d and d.items and d.items[word] + return (def and def.name) or word + end + local def = d and d.moves and d.moves[word] + return (def and def.name) or word +end + +-- BuenasPassword4's two rejection rolls, packed group-high over word-low +-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487). +local function rollPassword(save, day) + local buena = Save.crystalState(save).buenaPassword + buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16 + + (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1) + buena.day = day + return buena.word +end + +-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day +-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it. +local function currentPassword(vm) + local record = S.save(vm) + if not record then return 0 end + local buena = Save.crystalState(record).buenaPassword + local today = BugContest.now().day + if buena.word == nil or buena.day ~= today then rollPassword(record, today) end + if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end + return buena.word % 256 +end + +-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The +-- question is the script's own writetext at maps/RadioTower2F.asm:119. +M.AskRememberPassword = function(vm) + local yes = coroutine.yield({ kind = "yesorno" }) + S.answer(vm, yes and 1 or 0) +end + +-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`, +-- and :44-49 .PasswordIndices, which makes the menu's answer zero based. +M.BuenasPassword = function(vm) + local h = hooks(vm) + local packed = currentPassword(vm) + local group = math.floor(packed / 16) % 16 + if group >= NUM_PASSWORD_CATEGORIES then group = 0 end + local answer = packed % 4 + local words = {} + for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do + words[row + 1] = passwordWord(vm, group, row) + end + if not h.pushScreen then + S.answer(vm, 0) + return + end + local picked = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2BuenaPassword", { + mode = "password", + words = words, + width = BUENA_PASSWORDS[group + 1].points, + onDone = function(index) done(index or -1) end, + }) + if not ok then done(-1) end + end) + S.answer(vm, (picked == answer) and 1 or 0) +end + +-- data/items/buena_prizes.asm. +local BUENA_PRIZES = { + { item = "ULTRA_BALL", cost = 2 }, + { item = "FULL_RESTORE", cost = 2 }, + { item = "NUGGET", cost = 3 }, + { item = "RARE_CANDY", cost = 3 }, + { item = "PROTEIN", cost = 5 }, + { item = "IRON", cost = 5 }, + { item = "CARBOS", cost = 5 }, + { item = "CALCIUM", cost = 5 }, + { item = "HP_UP", cost = 5 }, +} + +-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles. +local PRIZE_TEXT = { + which = Strings.source("Which prize would\nyou like?"), + confirm = Strings.source("{STRBUF}?\nIs that right?"), + hereYouGo = Strings.source("Here you go!"), + notEnough = Strings.source("You don't have\nenough points."), + noRoom = Strings.source("You have no room\nfor it."), + comeAgain = Strings.source("Oh. Please come\nback again!"), +} + +-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at +-- maps/RadioTower2F.asm:144-146 and this counter read one store. +local function blueCardBalance(vm) + if not vm.readVarFn then return 0 end + local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0) + if value < 0 then value = 0 end + return math.min(value, BLUE_CARD_POINT_CAP) +end + +local function setBlueCardBalance(vm, value) + if vm.writeVarFn then + vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256) + end +end + +-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110). +local function receiveItem(vm, itemId) + local record = S.save(vm) + if not record then return false end + record.inventory = record.inventory or {} + return Bag.add(record, itemId, 1, S.data(vm)) and true or false +end + +M.BuenaPrize = function(vm) + local h = hooks(vm) + if not h.pushScreen then return end + local d = S.data(vm) + local items = d and d.items + local rows = {} + for i, prize in ipairs(BUENA_PRIZES) do + local def = items and items[prize.item] + rows[i] = { + item = prize.item, + cost = prize.cost, + name = (def and def.name) or prize.item, + } + end + + while true do + -- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens + -- over it, so the box is held the way a `yesorno` page is held. + S.showRawHeld(vm, Strings(PRIZE_TEXT.which)) + local pick = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2BuenaPassword", { + mode = "prize", + prizes = rows, + balance = blueCardBalance(vm), + onDone = function(index) done(index or 0) end, + }) + if not ok then done(0) end + end) + -- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press. + if not (type(pick) == "number" and rows[pick]) then break end + + local row = rows[pick] + vm:setStringBuffer(row.name) + S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm)) + local sure = coroutine.yield({ kind = "yesorno" }) + if sure then + -- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem, + -- and only a delivered item spends the points. + local balance = blueCardBalance(vm) + if balance < row.cost then + vm:showRaw(Strings(PRIZE_TEXT.notEnough)) + else + if not receiveItem(vm, row.item) then + vm:showRaw(Strings(PRIZE_TEXT.noRoom)) + else + setBlueCardBalance(vm, balance - row.cost) + if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end + vm:showRaw(Strings(PRIZE_TEXT.hereYouGo)) + end + end + end + end + -- ../pokecrystal/engine/events/buena.asm:138-145 .done. + vm:showRaw(Strings(PRIZE_TEXT.comeAgain)) +end + +-------------------------------------------------------------------------- +-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18 +-------------------------------------------------------------------------- + +-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289) +-- in jumptable order, with each text_ram buffer spelled as a directive. +local SEER_TEXT = { + intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"), + cantTell = Strings.source( + "Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"), + nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"), + timeLevel = Strings.source( + "The time was\n%s!\fIts level was %s!\fAm I good or what?"), + trade = Strings.source( + "Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"), + noLocation = Strings.source( + "What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n" + .. "You are special.\fI can't tell where\nyou met it, but it\v" + .. "was at level %s.\fAm I good or what?"), + egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"), + doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"), +} + +-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field +-- three marks the rows that splice the nickname in. +local SEER_ADVICE = { + { 9, Strings.source( + "Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") }, + { 29, Strings.source( + "Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v" + .. "more confident."), true }, + { 59, Strings.source( + "Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true }, + { 89, Strings.source( + "Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f" + .. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true }, + { 100, Strings.source( + "Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n" + .. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n" + .. "seeing %s\fin battle would\nexcite anyone."), true }, + { 255, Strings.source( + "Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") }, +} + +-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and +-- UnknownCaughtData's "Unknown@" (:215). +local SEER_TIMES = { + Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"), +} +local SEER_UNKNOWN = Strings.source("Unknown") +local SEER_NO_LEVEL = Strings.source("???") + +-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and +-- constants/battle_constants.asm:4 EGG_LEVEL. +local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5 + +-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179). +local function caughtLevel(byte0) + local level = byte0 % 0x40 + if level == 0 then return nil, Strings(SEER_NO_LEVEL) end + if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end + return level, tostring(level) +end + +-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer +-- is the SEERACTION_* its two sentinel arms override wSeerAction with. +local function caughtLocation(vm, byte1) + local landmark = byte1 % 0x80 + if landmark == 0 then return Strings(SEER_UNKNOWN), nil end + if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end + if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end + local record = Nests.landmark(S.data(vm), landmark) + local name = record and record.name + if not name then return Strings(SEER_UNKNOWN), nil end + -- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte + -- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it. + return (tostring(name):gsub("\n", " ")), nil +end + +-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte. +local function seerAdvice(vm, mon, level) + local diff = ((mon.level or 0) - (level or 0)) % 256 + local name = Mon.displayName(mon) + for _, row in ipairs(SEER_ADVICE) do + if diff <= row[1] then + if row[3] then return vm:showRaw(Strings(row[2], name, name)) end + return vm:showRaw(Strings(row[2])) + end + end +end + +M.PokeSeer = function(vm) + vm:showRaw(Strings(SEER_TEXT.intro)) + local _, mon = S.selectMon(vm, "choose") + -- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel. + if not mon then + vm:showRaw(Strings(SEER_TEXT.doNothing)) + return + end + -- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31. + if mon.isEgg then + vm:showRaw(Strings(SEER_TEXT.egg)) + return + end + + local byte0, byte1 = Mon.packCaughtData(mon) + -- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133). + if byte0 == 0 and byte1 == 0 then + vm:showRaw(Strings(SEER_TEXT.cantTell)) + return + end + + -- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second + -- byte is commented out, so only the HIGH byte decides "traded". + local record = S.save(vm) + local playerId = (record and record.player and record.player.id) or 0 + local traded = math.floor((mon.otId or 0) / 256) % 256 + ~= math.floor(playerId / 256) % 256 + + local level, levelText = caughtLevel(byte0) + local place, override = caughtLocation(vm, byte1) + local name = Mon.displayName(mon) + + -- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89). + if override == "cant_tell" then + vm:showRaw(Strings(SEER_TEXT.cantTell)) + return + end + -- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95). + if override == "level_only" then + vm:showRaw(Strings(SEER_TEXT.noLocation, levelText)) + return seerAdvice(vm, mon, level) + end + + local time = math.floor(byte0 / 0x40) + local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN) + + if traded then + -- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79). + local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN) + vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name)) + else + -- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70). + vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place)) + end + vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText)) + seerAdvice(vm, mon, level) +end + +-------------------------------------------------------------------------- +-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191 +-------------------------------------------------------------------------- + +-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194, +-- :201); either hit is TRUE. +M.UnusedFindItemInPCOrBag = function(vm) + local h = hooks(vm) + local index = vm.scriptVar or 0 + local record = S.save(vm) + local d = S.data(vm) + local id + for key, def in pairs((d and d.items) or {}) do + if type(def) == "table" and def.index == index then id = key break end + end + local pc = record and record.pcItems + if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then + S.answer(vm, 1) + return + end + if h.hasItem and h.hasItem(index) then + S.answer(vm, 1) + return + end + S.answer(vm, 0) +end + +-------------------------------------------------------------------------- +-- The wall map -- engine/events/specials.asm:100 +-------------------------------------------------------------------------- + +-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it +-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145 +-- TownMapScript and engine/overworld/decorations.asm:1005 +-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`. +-- +-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the +-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for +-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is +-- src/ui/gen2/Pokegear.lua's `townMap` mode. +M.OverworldTownMap = function(vm) + local h = S.hooks(vm) + if not h.pushScreen then return end + Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2Pokegear", { + townMap = true, + onClose = function() done(true) end, + }) + if not ok then done(false) end + end) +end + +-------------------------------------------------------------------------- +-- The printed diploma -- engine/events/specials.asm:448 +-------------------------------------------------------------------------- + +-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page +-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`, +-- engine/events/diploma.asm:12 -- and only then reaches for the serial port. +-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap +-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on +-- the cartridge either: page 1 IS the whole visible routine. +-- +-- The two SendScreenToPrinter passes are the same missing peripheral +-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting +-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for +-- a failed print and neither does this. +M.PrintDiploma = function(vm) + local h = S.hooks(vm) + if not h.showDiploma then return end + Specials.block(vm, function(done) + h.showDiploma(function() done(true) end) + end) +end + +return M diff --git a/src/script/gen2/specials/crystal_story.lua b/src/script/gen2/specials/crystal_story.lua new file mode 100644 index 00000000..548ddc69 --- /dev/null +++ b/src/script/gen2/specials/crystal_story.lua @@ -0,0 +1,298 @@ +-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148 +-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160 +-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck. + +local Specials = require("src.script.gen2.Specials") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +local S = Specials.shared +local M = {} + +-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE. +local VAR_BATTLETYPE = 0x03 +-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI. +local BATTLETYPE_CELEBI = 11 + +-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the +-- routine writes into wScriptVar before each CheckOwnMonAnywhere. +local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" } + +-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is +-- CheckOwnMonAnywhere (:48) with the answer already written. +M.BeastsCheck = function(vm) + local monCheck = Specials.HANDLERS.MonCheck + local h = S.hooks(vm) + for _, name in ipairs(BEASTS) do + vm.scriptVar = (h.monIndex and h.monIndex(name)) or name + monCheck(vm) + if vm.scriptVar ~= S.TRUE then + S.answer(vm, S.FALSE) + return + end + end + S.answer(vm, S.TRUE) +end + +-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1. +local DRATINI = "DRATINI" +local DRATINI_MOVESETS = { + [0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" }, + [1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" }, +} + +-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16 +-- .CheckForDratini walks the party BACKWARDS from the last slot. +M.GiveDratini = function(vm) + local set = DRATINI_MOVESETS[vm.scriptVar or 0] + if not set then return end + local list = S.party(vm) + local target + for index = #list, 1, -1 do + local mon = list[index] + if mon and mon.species == DRATINI then + target = mon + break + end + end + if not target then return end + -- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from + -- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped. + local defs = S.data(vm) + local moves = defs and defs.moves + target.moves = target.moves or {} + for index, id in ipairs(set) do + local pp = (moves and moves[id] and moves[id].pp) or 0 + target.moves[index] = { id = id, pp = pp, maxPp = pp } + end +end + +-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in +-- order; the macro (:5) accumulates them and stores total * $ffff / 100. +local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 } + +local ODD_EGG_PROBABILITIES = {} +do + local total = 0 + for index, percent in ipairs(ODD_EGG_PERCENTS) do + total = total + percent + ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100) + end +end + +-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per +-- NICKNAMED_MON_STRUCT. +local ODD_EGGS = { + { species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20, + moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + pp = { 30, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 17, attack = 9, defense = 6, speed = 11, + specialAttack = 8, specialDefense = 8 } }, + { species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20, + moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + pp = { 30, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 17, attack = 9, defense = 7, speed = 12, + specialAttack = 9, specialDefense = 9 } }, + { species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20, + moves = { "POUND", "CHARM", "DIZZY_PUNCH" }, + pp = { 35, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 20, attack = 7, defense = 7, speed = 6, + specialAttack = 9, specialDefense = 10 } }, + { species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20, + moves = { "POUND", "CHARM", "DIZZY_PUNCH" }, + pp = { 35, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 20, attack = 7, defense = 8, speed = 7, + specialAttack = 10, specialDefense = 11 } }, + { species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5, + eggSteps = 20, + moves = { "SING", "CHARM", "DIZZY_PUNCH" }, + pp = { 15, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 24, attack = 8, defense = 6, speed = 6, + specialAttack = 9, specialDefense = 7 } }, + { species = "IGGLYBUFF", otId = 768, experience = 125, level = 5, + eggSteps = 20, + moves = { "SING", "CHARM", "DIZZY_PUNCH" }, + pp = { 15, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 24, attack = 8, defense = 7, speed = 7, + specialAttack = 10, specialDefense = 8 } }, + { species = "SMOOCHUM", otId = 3584, experience = 125, level = 5, + eggSteps = 20, + moves = { "POUND", "LICK", "DIZZY_PUNCH" }, + pp = { 35, 30, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 8, defense = 6, speed = 11, + specialAttack = 13, specialDefense = 11 } }, + { species = "SMOOCHUM", otId = 512, experience = 125, level = 5, + eggSteps = 20, + moves = { "POUND", "LICK", "DIZZY_PUNCH" }, + pp = { 35, 30, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 8, defense = 7, speed = 12, + specialAttack = 14, specialDefense = 12 } }, + { species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20, + moves = { "EMBER", "DIZZY_PUNCH" }, + pp = { 25, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 12, defense = 8, speed = 13, + specialAttack = 12, specialDefense = 10 } }, + { species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20, + moves = { "EMBER", "DIZZY_PUNCH" }, + pp = { 25, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 12, defense = 9, speed = 14, + specialAttack = 13, specialDefense = 11 } }, + { species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20, + moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + pp = { 30, 30, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 11, defense = 8, speed = 14, + specialAttack = 11, specialDefense = 10 } }, + { species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20, + moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + pp = { 30, 30, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 11, defense = 9, speed = 15, + specialAttack = 12, specialDefense = 11 } }, + { species = "TYROGUE", otId = 2560, experience = 125, level = 5, + eggSteps = 20, + moves = { "TACKLE", "DIZZY_PUNCH" }, + pp = { 35, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 18, attack = 8, defense = 8, speed = 8, + specialAttack = 8, specialDefense = 8 } }, + { species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20, + moves = { "TACKLE", "DIZZY_PUNCH" }, + pp = { 35, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 18, attack = 8, defense = 9, speed = 9, + specialAttack = 9, specialDefense = 9 } }, +} + +-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME +-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname. +local ODD_EGG_OT = "ODD" +local ODD_EGG_NICKNAME = "EGG" +local EGG_TICKET = "EGG_TICKET" + +-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the +-- cumulative table; the $ffff break is :17. +local function oddEggIndex(roll) + for index = 1, #ODD_EGG_PROBABILITIES do + local probability = ODD_EGG_PROBABILITIES[index] + if probability >= 0xffff then return index end + if roll <= probability then return index end + end + return #ODD_EGG_PROBABILITIES +end + +-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH +-- bytes copied verbatim. +local function buildOddEgg(data, row, save) + if not (data and row) then return nil end + local moves = {} + for index, id in ipairs(row.moves) do + local pp = row.pp[index] or 0 + moves[index] = { id = id, pp = pp, maxPp = pp } + end + local dvs = { + attack = row.dvs.attack, defense = row.dvs.defense, + speed = row.dvs.speed, special = row.dvs.special, + } + local egg = Mon.new(data, row.species, row.level, { + dvs = dvs, + moves = moves, + -- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP` + hp = 0, + nickname = ODD_EGG_NICKNAME, + }) + if not egg then return nil end + egg.experience = row.experience + egg.stats = { + hp = row.stats.hp, attack = row.stats.attack, + defense = row.stats.defense, speed = row.stats.speed, + specialAttack = row.stats.specialAttack, + specialDefense = row.stats.specialDefense, + } + egg.maxHp = row.stats.hp + egg.hp = 0 + -- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct + -- keeps the hatchling's own species. + egg.isEgg = true + -- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`, + -- the byte MON_HAPPINESS holds while the thing is an egg. + egg.eggSteps = row.eggSteps + egg.ot = ODD_EGG_OT + egg.otName = ODD_EGG_OT + egg.otId = row.otId + if save then Mon.stampOT(save, egg) end + return egg +end + +-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the +-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's. +M.GiveOddEgg = function(vm) + local record = S.save(vm) + local data = S.data(vm) + if not (record and data) then return end + local party = record.party or {} + record.party = party + if #party >= Mon.PARTY_SIZE then return end + local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)] + local egg = buildOddEgg(data, row, record) + if not egg then return end + -- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET, + -- ahead of the party write. + local h = S.hooks(vm) + local ticket = h.itemIndex and h.itemIndex(EGG_TICKET) + if ticket and h.takeItem then h.takeItem(ticket, 1) end + party[#party + 1] = egg +end + +-- ../pokecrystal/engine/events/unown_walls.asm:1, run by +-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10. +M.HoOhChamber = function(vm) + if not UnownWords.leadIsHoOh(S.party(vm)) then return end + UnownWords.openWall(vm.events, "HO_OH") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:13, run by +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38 +-- MON_ITEM. +M.OmanyteChamber = function(vm) + if UnownWords.wallOpened(vm.events, "OMANYTE") then return end + local h = S.hooks(vm) + local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE) + local inPack = index and h.hasItem and h.hasItem(index) + if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end + UnownWords.openWall(vm.events, "OMANYTE") +end + +-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is +-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that. +M.CelebiShrineEvent = function(vm) + if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end + vm.celebiArmed = true +end + +-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit +-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542. +M.CheckCaughtCelebi = function(vm) + local caught = vm.celebiArmed == true and vm.battleOutcome == "caught" + vm.celebiArmed = nil + if caught then + local record = S.save(vm) + if record then + local Save = require("src.core.gen2.Save") + Save.crystalState(record).celebiCaught = true + end + end + S.answer(vm, caught and S.TRUE or S.FALSE) +end + +return M diff --git a/src/script/gen2/specials/unown_words.lua b/src/script/gen2/specials/unown_words.lua new file mode 100644 index 00000000..e3a25664 --- /dev/null +++ b/src/script/gen2/specials/unown_words.lua @@ -0,0 +1,29 @@ +-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four +-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123, +-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86, +-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85. + +local Specials = require("src.script.gen2.Specials") +local UnownWords = require("src.world.gen2.UnownWords") + +local S = Specials.shared + +local M = {} + +M.DisplayUnownWords = function(vm) + local h = S.hooks(vm) + local world = h.world + local game = world and world.game + if not (game and game.stack) then return end + local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0) + if not wall then return end + S.block(vm, function(done) + local screen = UnownWords.new(game, { + wall = wall, world = world, onClose = function() done(true) end, + }) + local ok = pcall(game.stack.push, game.stack, screen) + if not ok then done(false) end + end) +end + +return M diff --git a/src/sync/SyncMods.lua b/src/sync/SyncMods.lua index 814447e1..51adfda2 100644 --- a/src/sync/SyncMods.lua +++ b/src/sync/SyncMods.lua @@ -7,7 +7,7 @@ SyncMods.MAX_OPTION_TEXT = 256 local function versions() local ok, GameVersion = pcall(require, "src.core.GameVersion") if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end - return { "red", "blue", "yellow", "gold", "silver" } + return { "red", "blue", "yellow", "gold", "silver", "crystal" } end local function defaultDeps() diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index e52b8d12..424bfa58 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -39,14 +39,30 @@ local BUILTIN = { -- screens and do have ids. local GEN2 = { "BankOfMom", - "BattleState", "BattleTransition", "BoxMenu", "CardFlip", + "BattleState", + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 the CHALLENGE + -- / EXPLANATION / CANCEL desk menu, Crystal only. + "BattleTowerMenu", + "BattleTransition", "BoxMenu", + -- ../pokecrystal/engine/events/buena.asm:1 the radio-password prompt behind + -- `special BuenasPassword`, Crystal only. + "BuenaPassword", + "CardFlip", -- The Pokecenter PC's whose-PC top menu and the player's item PC behind it; -- Gen2PcMenu below is the storage system both BILL's PC rows open. "CenterPcMenu", "ContestMenu", - "CopyrightSplash", "Credits", "DayCareMenu", "DecorationMenu", "Diploma", + "CopyrightSplash", "Credits", + -- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only + -- peer of GoldSilverIntro below. + "CrystalIntro", + "DayCareMenu", "DecorationMenu", "Diploma", "EggHatchAnim", "ElevatorMenu", "EvolutionAnim", - "GameFreakPresents", "GoldSilverIntro", "HallOfFame", "HeldItemMenu", + "GameFreakPresents", + -- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, the Crystal-only + -- screen PlayerProfileSetup runs before Oak's speech. + "GenderSelect", + "GoldSilverIntro", "HallOfFame", "HeldItemMenu", -- Gen2InitClock is both timeset.asm screens: the new-game hour/minute pair -- OakSpeech opens with, and Mom's day-of-week wheel. "InitClock", @@ -57,7 +73,11 @@ local GEN2 = { "MailCompose", "MailMenu", "MailRead", "MailboxMenu", -- Gen2MapRadio is the in-house wall radio (`special MapRadio`), not a card. "MapRadio", - "MainMenu", "MartMenu", "MoveDeleter", "NamePick", "NamingScreen", "OakSpeech", + "MainMenu", "MartMenu", "MoveDeleter", + -- ../pokecrystal/engine/events/move_tutor.asm:1 the Goldenrod tutor's own + -- move/party pages, Crystal only. + "MoveTutor", + "NamePick", "NamingScreen", "OakSpeech", "OptionsMenu", "PackMenu", "PartyMenu", "PcMenu", "PhotoStudio", "PokedexMenu", "Pokegear", "SaveMenu", "ScriptMenu", "SlotMachine", "StartMenu", "SummaryMenu", "TitleState", "TradeAnim", "TradeMenu", @@ -66,12 +86,31 @@ local GEN2 = { "UnownPrinter", "UnownPuzzle", } +local GEN2_PENDING = { + BattleTowerMenu = true, + BuenaPassword = true, + MoveTutor = true, +} + +local function moduleExists(name) + local path = "src/ui/gen2/" .. name .. ".lua" + local handle = io.open(path, "r") + if handle then + handle:close() + return true + end + local lfs = love and love.filesystem + return (lfs and lfs.getInfo and lfs.getInfo(path)) ~= nil +end + -- The full ids, in the same order, for tests and for the mod docs. Screens.GEN2_IDS = {} for _, name in ipairs(GEN2) do local id = "Gen2" .. name BUILTIN[id] = "src.ui.gen2." .. name - Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id + if not GEN2_PENDING[name] or moduleExists(name) then + Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id + end end local cache = {} diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index f5d9adae..365b8f67 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -25,10 +25,12 @@ local Catching = require("src.battle.gen2.Catching") local Chrome = require("src.ui.gen2.Chrome") local Evolution = require("src.core.gen2.Evolution") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local Font = require("src.render.Font") local HpBar = require("src.battle.gen2.HpBar") local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") +local MonAnim = require("src.render.MonAnim") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") local Prize = require("src.battle.gen2.Prize") @@ -306,6 +308,11 @@ function BattleState.new(game, opts) self.playerBackTrueColor = false local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud local backPath = hudGfx and hudGfx.playerBack + -- GetTrainerBackpic's gender arm, under the Dude exception + -- (../pokecrystal/engine/battle/core.asm:8984-9008). + if hudGfx and hudGfx.playerBackFemale and Gen2Save.isFemale(self.save) then + backPath = hudGfx.playerBackFemale + end if self.tutorial and hudGfx and hudGfx.dudeBack then backPath = hudGfx.dudeBack end @@ -586,6 +593,65 @@ function BattleState:pic(mon, back) return cached or nil, trueColor, path end +-- Crystal's animated front pics. A cache with no `anim` row -- every Gold +-- and Silver one -- gets nil here and the static pic is drawn as before. +function BattleState:animData(mon) + local def = self.pokemon and mon and self.pokemon[mon.species] + if not def then return nil end + if mon.species == Unown.SPECIES and def.letters then + local entry = def.letters[Unown.monLetter(mon)] + if entry and entry.anim then return entry.anim end + end + return def.anim +end + +-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic. +-- ../pokecrystal/engine/battle/core.asm:9112-9113 +function BattleState:startFrontAnim(mon) + self.frontAnim = nil + local data = self:animData(mon) + if not data then return end + local cached = self.picCache[data.sheet] + if cached == nil then + local ok, image = pcall(Assets.image, data.sheet) + cached = ok and image or false + self.picCache[data.sheet] = cached + end + if not cached then return end + local runner = MonAnim.new(data, "battle") + if not runner then return end + local size = data.tiles * 8 + self.frontAnim = { mon = mon, runner = runner, sheet = cached, size = size, + quads = {} } +end + +-- AnimateFrontpic's .loop, one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function BattleState:stepFrontAnim() + local state = self.frontAnim + if not state then return end + state.runner:update() + if state.runner:finished() then self.frontAnim = nil end +end + +-- The sheet is one column of whole pictures, base first, so a frame is one +-- quad at the size the static pic would have been. +function BattleState:frontAnimFrame(mon) + local state = self.frontAnim + if not (state and mon and state.mon == mon) then return nil end + local frame = state.runner:currentFrame() + if frame <= 0 then return nil end + local quad = state.quads[frame] + if not quad then + local w, h = state.sheet:getDimensions() + if (frame + 1) * state.size > h then return nil end + quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size, + w, h) + state.quads[frame] = quad + end + return state.sheet, quad, state.size +end + -- The battle_sprite_scales registry: record id -> { path, scale }, keyed by the -- ASSET PATH the pic is drawn from rather than by species, which is the only -- handle there is on the pics that are not a species' own (the player's @@ -696,6 +762,13 @@ function BattleState:drawPic(mon, back) end local G = love.graphics local w, h = image:getDimensions() + -- Crystal only: the frame the animation is showing replaces the static + -- picture in the same box, at the same size. + local animSheet, animQuad, animSize + if not (back or trainerBack or enemyTrainer or doll) then + animSheet, animQuad, animSize = self:frontAnimFrame(mon) + if animSheet then w, h = animSize, animSize end + end local px, py local boxTiles if back then @@ -772,6 +845,10 @@ function BattleState:drawPic(mon, back) scale, scale) return end + if animQuad then + G.draw(animSheet, animQuad, px, py, 0, scale, scale) + return + end G.draw(image, px, py, 0, scale, scale) end -- A mod-supplied pic that says it is already coloured is drawn as it is: @@ -1588,7 +1665,10 @@ function BattleState:advanceQueue() if event.intro then self.introTextShown = true end -- BattleStartMessage's `.not_shiny` cries the wild mon before its own line -- (engine/battle/core.asm:8718-8721). - if event.cry then self:playCry(event.cry) end + if event.cry then + self:playCry(event.cry) + self:startFrontAnim(event.cry) + end -- A text_asm tail that plays its own sound, the way Text_BallCaught's -- sound_caught_mon rides the "Gotcha!" line rather than following it. if event.sfx then @@ -1694,6 +1774,7 @@ function BattleState:finishSendOut(after) if not after then return end self:playCry(after.mon) if after.side == "enemy" then + self:startFrontAnim(after.mon) self.showEnemyHud = true else self.showPlayerHud = true @@ -1964,6 +2045,7 @@ function BattleState:update(_dt) -- this state is only still here because ExitBattle has not cleaned up yet. if self.phase == "evolving" or self.phase == "done" then return end self:updateAlarm() + self:stepFrontAnim() local input = self.game and self.game.input if not input then return end @@ -2489,6 +2571,7 @@ function BattleState:openTutorialPack() end Screens.push(self.game, "Gen2PackMenu", { battle = true, + tutorial = true, save = CatchTutorial.dudeSave(), -- An empty world rather than the real one: a DUDE pocket must not reach -- World:useFieldItem, because these buffers are not the player's bag and diff --git a/src/ui/gen2/BattleTowerMenu.lua b/src/ui/gen2/BattleTowerMenu.lua new file mode 100644 index 00000000..b47c0178 --- /dev/null +++ b/src/ui/gen2/BattleTowerMenu.lua @@ -0,0 +1,220 @@ +-- ../pokecrystal/mobile/mobile_46.asm:137-177 _BattleTowerRoomMenu and the +-- jumptable at :625-641 it drives. + +local BattleTower = require("src.core.gen2.BattleTower") +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local BattleTowerMenu = {} +BattleTowerMenu.__index = BattleTowerMenu +BattleTowerMenu.isOpaque = false + +-- ../pokecrystal/mobile/mobile_46.asm:3854-3858 MenuHeader_119cf7, +-- `menu_coords 12, 7, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`. +local PICK_X, PICK_Y, PICK_W, PICK_H = 12, 7, 8, 5 +-- ../pokecrystal/mobile/mobile_46.asm:1178-1183, :1222 and :1147 +local ROW_X, ROW_Y = 13, 9 +local ARROW_X, UP_Y, DOWN_Y = 16, 8, 10 + +-- ../pokecrystal/mobile/mobile_46.asm:4635-4639 MenuHeader_11a2de and +-- :4516-4530 BattleTowerRoomMenu2_PlaceYesNoMenu. +local YN_X, YN_Y, YN_W, YN_H = 14, 7, 6, 5 +local YN_TEXT_X, YES_Y, NO_Y = 16, 8, 10 + +-- ../pokecrystal/constants/text_constants.asm:25-32, the box SpeechTextbox +-- fills at ../pokecrystal/mobile/mobile_46.asm:5267. +local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4 +local SAY_TEXT_X, SAY_TEXT_Y = 1, 14 + +-- ../pokecrystal/mobile/mobile_46.asm:3803-3813, `ld a, $80 / ld [wcd50]` +-- counted down one per frame before the menu restarts. +local MESSAGE_FRAMES = 0x80 + +-- ../pokecrystal/mobile/mobile_46.asm:4609-4610, the code +-- BattleTowerRoomMenu_Cleanup (:512-513) copies into wScriptVar. The chosen +-- room is a separate byte (w3_d800 at :1285), so onDone answers the level +-- group and nil for the cancel. +BattleTowerMenu.CANCELLED = 0x0a + +-- ../pokecrystal/mobile/mobile_46.asm:5488-5491 +local PICK_TEXT = Strings.source("What level do you\nwant to challenge?") +-- ../pokecrystal/mobile/mobile_46.asm:5459-5462 +local TOPS_TEXT = Strings.source("A party POKéMON\ntops this level.") +-- ../pokecrystal/mobile/mobile_46.asm:5464-5471 +local UBER_TEXT = Strings.source( + "%s may go\nonly to BATTLE\nROOMS that are\nLv.70 or higher.") +-- ../pokecrystal/mobile/mobile_46.asm:5473-5476 +local QUIT_TEXT = Strings.source("Cancel your BATTLE\nROOM challenge?") +-- ../pokecrystal/constants/charmap.asm:89 and :192, tiles $61 and $ee. +local UP_ARROW = "\xe2\x96\xb2" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- ../pokecrystal/mobile/mobile_46.asm:3880 and :4623-4627 +local CANCEL_LABEL = Strings.source("CANCEL") +local YES_LABEL = Strings.source("YES") +local NO_LABEL = Strings.source("NO") + +-- ../pokecrystal/mobile/mobile_46.asm:3869-3879 Strings_L10ToL100, six tiles +-- a row. +function BattleTowerMenu.levelLabel(group) + return string.format(" L:%-3d", group * 10):sub(1, 6) +end + +-- opts: save, party, rows (BattleTower.levelGroupRows), monName, +-- onDone(levelGroup) with nil for the cancel +function BattleTowerMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BattleTowerMenu) + self.game = game + self.data = (game and game.data) or {} + self.save = opts.save or (game and game.save) + self.party = opts.party or (self.save and self.save.party) or {} + self.rows = opts.rows or BattleTower.levelGroupRows(self.save) + self.monName = opts.monName + self.onDone = opts.onDone + -- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153) + self.cursor = 1 + self.phase = "pick" + self.message = PICK_TEXT + return self +end + +function BattleTowerMenu:wantsFillScale() return true end + +function BattleTowerMenu:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +-- The last row is CANCEL (../pokecrystal/mobile/mobile_46.asm:1160, :1165). +function BattleTowerMenu:rowCount() + return #self.rows + 1 +end + +function BattleTowerMenu:finish(group) + if self.done then return end + self.done = true + local game = self.game + if game and game.stack and game.stack:top() == self then game.stack:pop() end + if self.onDone then self.onDone(group) end +end + +-- ../pokecrystal/mobile/mobile_46.asm:3794-3816: the refusal is printed, held +-- for $80 frames and the menu restarts at jumptable index 0. +function BattleTowerMenu:refuse(text) + self.phase = "message" + self.message = text + self.wait = MESSAGE_FRAMES +end + +-- ../pokecrystal/mobile/mobile_46.asm:1258-1286 `.a_button` +function BattleTowerMenu:confirm() + local row = self.rows[self.cursor] + if not row then + -- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c` + self.phase = "quit" + self.message = QUIT_TEXT + self.yes = true + return + end + if BattleTower.levelCheck(self.party, row.group) then + return self:refuse(TOPS_TEXT) + end + local uber = BattleTower.ubersCheck(self.party, row.group) + if uber then + local name = (self.monName and self.monName(uber)) or uber + return self:refuse(string.format(UBER_TEXT, name)) + end + self:finish(row.group) +end + +-- ../pokecrystal/mobile/mobile_46.asm:1240-1256: UP walks the level up and +-- rolls over to the first row, DOWN walks it back and rolls over to CANCEL. +function BattleTowerMenu:updatePick() + local input = self.game and self.game.input + if not input then return end + local count = self:rowCount() + if input:wasPressed("up") then + self.cursor = (self.cursor < count) and self.cursor + 1 or 1 + elseif input:wasPressed("down") then + self.cursor = (self.cursor > 1) and self.cursor - 1 or count + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:confirm() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = "quit" + self.message = QUIT_TEXT + self.yes = true + end +end + +-- ../pokecrystal/mobile/mobile_46.asm:4535-4621 +-- BattleTowerRoomMenu2_UpdateYesNoMenu. +function BattleTowerMenu:updateQuit() + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") then + self.yes = true + elseif input:wasPressed("down") then + self.yes = false + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.yes then return self:finish(nil) end + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + end +end + +function BattleTowerMenu:update(_dt) + if self.done then return end + if self.phase == "message" then + self.wait = (self.wait or 0) - 1 + if self.wait > 0 then return end + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + return + end + if self.phase == "quit" then return self:updateQuit() end + return self:updatePick() +end + +function BattleTowerMenu:drawPanel() + Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) + Chrome.printWrapped(self.message, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H) + if self.phase == "message" then + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.phase == "quit" then + Chrome.box(YN_X, YN_Y, YN_W, YN_H) + Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y) + Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y) + Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y) + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H) + local row = self.rows[self.cursor] + Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL, + ROW_X, ROW_Y) + Chrome.print(UP_ARROW, ARROW_X, UP_Y) + Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y) + love.graphics.setColor(1, 1, 1, 1) +end + +function BattleTowerMenu:draw() + self:drawPanel() +end + +return BattleTowerMenu diff --git a/src/ui/gen2/BuenaPassword.lua b/src/ui/gen2/BuenaPassword.lua new file mode 100644 index 00000000..c60adc9f --- /dev/null +++ b/src/ui/gen2/BuenaPassword.lua @@ -0,0 +1,170 @@ +-- Buena's two windows, Crystal only, chosen by `mode`: +-- +-- password ../pokecrystal/engine/events/buena.asm:1 BuenasPassword, whose +-- .MenuHeader is `menu_coords 0, 0, 10, 7` with the right edge +-- moved to left + the category's points byte + 2 (:9-12), and +-- whose .PasswordIndices answer is zero based (:44-49). +-- prize :64 BuenaPrize -- Buena_PlacePrizeMenuBox (:219), +-- Buena_PrizeMenu's scrolling list (:249-261) and +-- PrintBlueCardBalance's "Points" box (:210). 0 is a B press +-- (:236-245). +-- +-- Neither is opaque: engine/events/buena.asm:71-83 prints the prize question +-- BEFORE the list goes up over it, and the password menu stands on the map. + +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local BuenaPassword = {} +BuenaPassword.__index = BuenaPassword +BuenaPassword.isOpaque = false + +-- GetMenuTextStartCoord (../pokecrystal/home/menu.asm:214) on STATICMENU_CURSOR +-- with no STATICMENU_NO_TOP_SPACING: labels at box + (2,2), cursor one left. +local WORD_X, WORD_Y, WORD_SPACING = 2, 2, 2 +local WORD_BOX_H = 8 + +-- engine/events/buena.asm:219 and :249, as menu_coords lays them down. +local FIELD_X, FIELD_Y, FIELD_W, FIELD_H = 0, 0, 18, 12 +local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 1, 16, 9 +local LIST_X, LIST_Y, LIST_SPACING = 2, 2, 2 +-- ScrollingMenu_CallFunctions1and2's `add hl, de` on +-- wMenuData_ScrollingMenuWidth (engine/menus/scrolling_menu.asm:424-431). +local POINTS_X = LIST_X + 13 +local VISIBLE_ROWS = 4 +local ARROW_X = LIST_BOX_X + LIST_BOX_W - 1 +local ARROW_UP_Y, ARROW_DOWN_Y = LIST_BOX_Y, LIST_BOX_Y + LIST_BOX_H - 1 + +-- BlueCardBalanceMenuHeader (engine/events/buena.asm:208) and .DrawBox's +-- PlaceString / two spaces / `lb bc, 1, 2` PrintNum (:181-203). +local BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H = 0, 11, 12, 3 +local BALANCE_LABEL_X, BALANCE_LABEL_Y = 1, 12 +local BALANCE_NUM_X = 8 +local POINTS_LABEL = Strings.source("Points") + +local UP_ARROW = "\xe2\x96\xb2" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- opts: mode, and then words/width/onDone(0..2) or prizes/balance/onDone(row) +function BuenaPassword.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BuenaPassword) + self.game = game + self.data = (game and game.data) or {} + self.mode = opts.mode == "prize" and "prize" or "password" + self.words = opts.words or {} + self.width = math.max(1, math.floor(tonumber(opts.width) or 10)) + self.prizes = opts.prizes or {} + self.balance = math.max(0, math.floor(tonumber(opts.balance) or 0)) + self.onDone = opts.onDone + -- engine/events/buena.asm:34 `db 1 ; default option` and :67-68 + -- `ld a, $1 / ld [wMenuSelection], a`. + self.index = 1 + self.scroll = 0 + return self +end + +function BuenaPassword:wantsFillScale() return true end + +function BuenaPassword:count() + if self.mode == "prize" then return #self.prizes end + return #self.words +end + +function BuenaPassword:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +function BuenaPassword:finish(value) + if self.done then return end + self.done = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone(value) end +end + +function BuenaPassword:ensureVisible() + local rows = math.min(VISIBLE_ROWS, self:count()) + if rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + rows then + self.scroll = self.index - rows + end + self.scroll = math.max(0, math.min(self.scroll, self:count() - rows)) +end + +function BuenaPassword:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local total = self:count() + if total <= 0 then return self:finish(self.mode == "prize" and 0 or -1) end + -- Neither header sets STATICMENU_WRAP (engine/events/buena.asm:39, :256). + if input:wasPressed("up") then + if self.index > 1 then self.index = self.index - 1 end + elseif input:wasPressed("down") then + if self.index < total then self.index = self.index + 1 end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.mode == "prize" then return self:finish(self.index) end + -- engine/events/buena.asm:44-49 .PasswordIndices is zero based. + return self:finish(self.index - 1) + elseif input:wasPressed("b") then + -- STATICMENU_DISABLE_B (engine/events/buena.asm:39). + if self.mode == "prize" then + self:playSfx("Sfx_ReadText2") + return self:finish(0) + end + end + self:ensureVisible() +end + +function BuenaPassword:drawPasswordPanel() + Chrome.box(0, 0, self.width + 3, WORD_BOX_H) + for i, word in ipairs(self.words) do + local ty = WORD_Y + (i - 1) * WORD_SPACING + if i == self.index then Chrome.cursor(WORD_X - 1, ty) end + Chrome.print(word, WORD_X, ty) + end +end + +function BuenaPassword:drawPrizePanel() + Chrome.box(FIELD_X, FIELD_Y, FIELD_W, FIELD_H) + Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local prize = self.prizes[i] + if prize then + local ty = LIST_Y + (row - 1) * LIST_SPACING + if i == self.index then Chrome.cursor(LIST_X - 1, ty) end + Chrome.print(prize.name, LIST_X, ty) + -- .PrintPrizePoints writes one char, `'0' + cost` (buena.asm:281-289). + Chrome.print(tostring(prize.cost % 10), POINTS_X, ty) + end + end + -- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only once scrolled, the ▼ every pass + -- (engine/menus/scrolling_menu.asm:348-358, :387-395). + if self.scroll > 0 then Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y) end + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y) + + Chrome.box(BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H) + Chrome.print(Strings(POINTS_LABEL), BALANCE_LABEL_X, BALANCE_LABEL_Y) + Chrome.print(Chrome.number(self.balance, 2), BALANCE_NUM_X, BALANCE_LABEL_Y) +end + +function BuenaPassword:drawPanel() + if self.mode == "prize" then return self:drawPrizePanel() end + return self:drawPasswordPanel() +end + +function BuenaPassword:draw() + self:drawPanel() + love.graphics.setColor(1, 1, 1, 1) +end + +return BuenaPassword diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua new file mode 100644 index 00000000..7f14ecef --- /dev/null +++ b/src/ui/gen2/CrystalIntro.lua @@ -0,0 +1,1282 @@ +-- The Crystal intro movie (pokecrystal engine/movie/intro.asm CrystalIntro), +-- transcribed: the 28-entry scene jumptable stepped once per frame +-- (engine/movie/intro.asm:58-94) over hSCX/hSCY, the two intro counters, live +-- wBGPals2 palettes and a real 32x32 BG map with CGB attributes. +-- Any button skips the whole thing (engine/movie/intro.asm:11-15). + +local bit = require("bit") + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Logger = require("src.core.Logger") +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local CrystalIntro = {} +CrystalIntro.__index = CrystalIntro +CrystalIntro.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local BG_TILES = 32 +local BG_PIXELS = BG_TILES * 8 + +local INTRO_MUSIC = "Music_CrystalOpening" + +local BLACK = { 0, 0, 0 } +local WHITE = { 255, 255, 255 } + +local OAM_PRIO = SpriteAnims.OAM_PRIO +local OAM_XFLIP = SpriteAnims.OAM_XFLIP +local OAM_YFLIP = SpriteAnims.OAM_YFLIP +-- OAM_BANK1 (constants/hardware.inc:997). +local OAM_BANK1 = 0x08 + +-------------------------------------------------------------------------- +-- Sprite-anim data, registered into SpriteAnims' shared tables +-------------------------------------------------------------------------- + +-- dbsprite (macros/gfx.asm:67-70): y byte first, tile counts mod $100. +local function s(xTile, yTile, xPixel, yPixel, tile, attr) + return { + y = (yTile * 8 + yPixel) % 256, + x = (xTile * 8 + xPixel) % 256, + tile = tile, + attr = attr, + } +end + +-- data/sprite_anims/oam.asm:815-852 .OAMData_IntroSuicune1. +local SUICUNE_1 = { + s( 1, -3, 0, 0, 0x05, 0), s( 2, -3, 0, 0, 0x06, 0), s( 3, -3, 0, 0, 0x07, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), s( 3, -1, 0, 0, 0x27, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s( 2, 0, 0, 0, 0x36, 0), + s(-4, 1, 0, 0, 0x40, 0), s(-3, 1, 0, 0, 0x41, 0), s(-2, 1, 0, 0, 0x42, 0), + s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), s( 1, 1, 0, 0, 0x45, 0), + s( 2, 1, 0, 0, 0x46, 0), s( 3, 1, 0, 0, 0x47, 0), + s(-4, 2, 0, 0, 0x50, 0), s(-3, 2, 0, 0, 0x51, 0), s( 3, 2, 0, 0, 0x57, 0), +} + +-- data/sprite_anims/oam.asm:854-883 .OAMData_IntroSuicune2. +local SUICUNE_2 = { + s( 0, -3, 0, 0, 0x04, 0), s( 1, -3, 0, 0, 0x05, 0), s( 2, -3, 0, 0, 0x06, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), s(-1, -1, 0, 0, 0x23, 0), + s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), s( 2, -1, 0, 0, 0x26, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), + s( 1, 1, 0, 0, 0x45, 0), + s(-1, 2, 0, 0, 0x53, 0), s( 0, 2, 0, 0, 0x54, 0), s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:885-916 .OAMData_IntroSuicune3. +local SUICUNE_3 = { + s( 0, -3, 0, 0, 0x04, 0), s( 1, -3, 0, 0, 0x05, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), + s( 1, 1, 0, 0, 0x45, 0), + s(-2, 2, 0, 0, 0x52, 0), s(-1, 2, 0, 0, 0x53, 0), s( 0, 2, 0, 0, 0x54, 0), + s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:918-950 .OAMData_IntroSuicune4. +local SUICUNE_4 = { + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), s( 3, -1, 0, 0, 0x27, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s( 2, 0, 0, 0, 0x36, 0), + s(-3, 1, 0, 0, 0x41, 0), s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), + s( 0, 1, 0, 0, 0x44, 0), s( 1, 1, 0, 0, 0x45, 0), + s(-3, 2, 0, 0, 0x51, 0), s(-2, 2, 0, 0, 0x52, 0), s( 0, 2, 0, 0, 0x54, 0), + s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:952-978 .OAMData_IntroPichu: 5x5 block, +-- OBJ pal 1, VRAM bank 1. +local PICHU = {} +for row = 0, 4 do + for col = 0, 4 do + PICHU[#PICHU + 1] = s(col - 3, row - 3, 4, 4, row * 16 + col, + 1 + OAM_BANK1) + end +end + +-- data/sprite_anims/oam.asm:980-997 .OAMData_IntroWooper: 4x4 block, +-- OBJ pal 2, VRAM bank 1. +local WOOPER = {} +for row = 0, 3 do + for col = 0, 3 do + WOOPER[#WOOPER + 1] = s(col - 3, row - 2, 4, 0, row * 4 + col, + 2 + OAM_BANK1) + end +end + +-- data/sprite_anims/oam.asm:999-1017 .OAMData_IntroUnown1/2/3. +local UNOWN_1 = { s(-1, -1, 4, 4, 0x00, 0) } +local UNOWN_2 = { + s(-1, 0, 0, 0, 0x00, 0), s(-1, -1, 0, 0, 0x01, 0), s( 0, -1, 0, 0, 0x02, 0), +} +local UNOWN_3 = { + s(-2, 1, 0, 0, 0x00, 0), s(-2, 0, 0, 0, 0x01, 0), s(-2, -1, 0, 0, 0x02, 0), + s(-1, -1, 0, 0, 0x03, 0), s(-1, -2, 0, 0, 0x04, 0), s( 0, -2, 0, 0, 0x05, 0), + s( 1, -2, 0, 0, 0x06, 0), +} + +-- data/sprite_anims/oam.asm:177-182 .OAMData_IntroUnownF2_1. +local UNOWN_F2_1 = { + s(-1, -1, 0, 0, 0x00, 0), s( 0, -1, 0, 0, 0x00, OAM_XFLIP), + s(-1, 0, 0, 0, 0x00, OAM_YFLIP), s( 0, 0, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1019-1028 .OAMData_IntroUnownF2_2. +local UNOWN_F2_2 = { + s(-2, -1, 0, 0, 0x00, 0), s(-1, -1, 0, 0, 0x01, 0), + s( 0, -1, 0, 0, 0x01, OAM_XFLIP), s( 1, -1, 0, 0, 0x00, OAM_XFLIP), + s(-2, 0, 0, 0, 0x00, OAM_YFLIP), s(-1, 0, 0, 0, 0x01, OAM_YFLIP), + s( 0, 0, 0, 0, 0x01, OAM_XFLIP + OAM_YFLIP), + s( 1, 0, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1030-1043 .OAMData_IntroUnownF2_3. +local UNOWN_F2_3 = { + s(-1, -3, 0, 0, 0x00, 0), s(-1, -2, 0, 0, 0x01, 0), s(-1, -1, 0, 0, 0x02, 0), + s( 0, -3, 0, 0, 0x00, OAM_XFLIP), s( 0, -2, 0, 0, 0x01, OAM_XFLIP), + s( 0, -1, 0, 0, 0x02, OAM_XFLIP), + s(-1, 0, 0, 0, 0x02, OAM_YFLIP), s(-1, 1, 0, 0, 0x01, OAM_YFLIP), + s(-1, 2, 0, 0, 0x00, OAM_YFLIP), + s( 0, 0, 0, 0, 0x02, OAM_XFLIP + OAM_YFLIP), + s( 0, 1, 0, 0, 0x01, OAM_XFLIP + OAM_YFLIP), + s( 0, 2, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1045-1066 .OAMData_IntroUnownF2_4_5: 4x5 block. +local UNOWN_F2_45 = {} +for row = 0, 4 do + for col = 0, 3 do + UNOWN_F2_45[#UNOWN_F2_45 + 1] = s(col - 2, row - 3, 0, 4, row * 4 + col, 0) + end +end + +-- data/sprite_anims/oam.asm:1068-1089 .OAMData_IntroSuicuneAway: a zigzag of +-- twenty grass tiles, OBJ pal 1, behind the BG. +local AWAY = {} +do + local ys = { 0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1 } + for index, y in ipairs(ys) do + AWAY[#AWAY + 1] = s(index, y, 0, 0, 0x00, 1 + OAM_PRIO) + end + local tail = { { -15, 0 }, { -14, 1 }, { -13, 2 }, { -12, 3 } } + for _, spot in ipairs(tail) do + AWAY[#AWAY + 1] = s(spot[1], spot[2], 0, 0, 0x00, 1 + OAM_PRIO) + end +end + +-- spriteanimoam bases (data/sprite_anims/oam.asm:120-136). +local OAMSETS = { + INTRO_SUICUNE_1 = { 0x00, SUICUNE_1 }, + INTRO_SUICUNE_2 = { 0x08, SUICUNE_2 }, + INTRO_SUICUNE_3 = { 0x60, SUICUNE_3 }, + INTRO_SUICUNE_4 = { 0x68, SUICUNE_4 }, + INTRO_PICHU_1 = { 0x00, PICHU }, + INTRO_PICHU_2 = { 0x05, PICHU }, + INTRO_PICHU_3 = { 0x0a, PICHU }, + INTRO_WOOPER = { 0x50, WOOPER }, + INTRO_UNOWN_1 = { 0x00, UNOWN_1 }, + INTRO_UNOWN_2 = { 0x01, UNOWN_2 }, + INTRO_UNOWN_3 = { 0x04, UNOWN_3 }, + INTRO_UNOWN_F_2_1 = { 0x00, UNOWN_F2_1 }, + INTRO_UNOWN_F_2_2 = { 0x01, UNOWN_F2_2 }, + INTRO_UNOWN_F_2_3 = { 0x03, UNOWN_F2_3 }, + INTRO_UNOWN_F_2_4 = { 0x08, UNOWN_F2_45 }, + INTRO_UNOWN_F_2_5 = { 0x1c, UNOWN_F2_45 }, + INTRO_SUICUNE_AWAY = { 0x80, AWAY }, +} + +local function f(oamset, duration, flags) + return { oamset = oamset, duration = duration, flags = flags or 0 } +end + +-- data/sprite_anims/framesets.asm:429-489. +local FRAMESETS = { + IntroSuicune = { + f("INTRO_SUICUNE_1", 3), f("INTRO_SUICUNE_2", 3), + f("INTRO_SUICUNE_3", 3), f("INTRO_SUICUNE_4", 3), "restart", + }, + IntroSuicune2 = { f("INTRO_SUICUNE_4", 3), f("INTRO_SUICUNE_1", 7), "end" }, + IntroPichu = { + f("INTRO_PICHU_1", 32), f("INTRO_PICHU_2", 7), f("INTRO_PICHU_3", 7), "end", + }, + IntroWooper = { f("INTRO_WOOPER", 3), "end" }, + IntroUnown1 = { + f("INTRO_UNOWN_1", 3), f("INTRO_UNOWN_2", 3), f("INTRO_UNOWN_3", 7), + f("delete", 0), + }, + IntroUnown2 = { + f("INTRO_UNOWN_1", 3, OAM_XFLIP), f("INTRO_UNOWN_2", 3, OAM_XFLIP), + f("INTRO_UNOWN_3", 7, OAM_XFLIP), f("delete", 0), + }, + IntroUnown3 = { + f("INTRO_UNOWN_1", 3, OAM_YFLIP), f("INTRO_UNOWN_2", 3, OAM_YFLIP), + f("INTRO_UNOWN_3", 7, OAM_YFLIP), f("delete", 0), + }, + IntroUnown4 = { + f("INTRO_UNOWN_1", 3, OAM_XFLIP + OAM_YFLIP), + f("INTRO_UNOWN_2", 3, OAM_XFLIP + OAM_YFLIP), + f("INTRO_UNOWN_3", 7, OAM_XFLIP + OAM_YFLIP), f("delete", 0), + }, + IntroUnownF2 = { + f("INTRO_UNOWN_F_2_1", 3), f("INTRO_UNOWN_F_2_2", 3), + f("INTRO_UNOWN_F_2_3", 3), f("INTRO_UNOWN_F_2_4", 7), + f("INTRO_UNOWN_F_2_5", 7), "end", + }, + IntroSuicuneAway = { f("INTRO_SUICUNE_AWAY", 3), "end" }, + IntroUnownF = { f("wait", 0), "end" }, +} + +local function reinit(st, framesetId) + st.framesetId = framesetId + st.duration = 0 + st.frame = -1 +end + +local SEQUENCES = {} + +-- SpriteAnimFunc_IntroSuicune (engine/sprite_anims/functions.asm:750-776): +-- idle while wIntroSceneTimer is 0, then the jump arc on a negated sine. +SEQUENCES.IntroSuicune = function(sys, st) + if (sys.timer or 0) == 0 then return end + st.yOffset = 0 + st.var2 = (st.var2 + 2) % 256 + st.yOffset = SpriteAnims.sine((256 - st.var2) % 256, 32) + reinit(st, "IntroSuicune2") +end + +-- SpriteAnimFunc_IntroPichuWooper (engine/sprite_anims/functions.asm:778-795). +SEQUENCES.IntroPichuWooper = function(_, st) + if st.var1 >= 20 then return end + st.var1 = st.var1 + 2 + st.yOffset = SpriteAnims.sine((256 - st.var1) % 256, 32) +end + +-- SpriteAnimFunc_IntroUnown (engine/sprite_anims/functions.asm:797-821): +-- circle at radius VAR1, three sine steps per frame. +SEQUENCES.IntroUnown = function(_, st) + local angle = st.jt + st.jt = (st.jt + 3) % 256 + st.yOffset = SpriteAnims.sine(angle, st.var1) + st.xOffset = SpriteAnims.cosine(angle, st.var1) +end + +-- SpriteAnimFunc_IntroUnownF (engine/sprite_anims/functions.asm:823-829): +-- wSlotsDelay aliases wIntroSceneFrameCounter (ram/wram.asm:1602,1649). +SEQUENCES.IntroUnownF = function(sys, st) + if (sys.counter or 0) ~= 0x40 then return end + reinit(st, "IntroUnownF2") +end + +-- SpriteAnimFunc_IntroSuicuneAway (engine/sprite_anims/functions.asm:831-837). +SEQUENCES.IntroSuicuneAway = function(_, st) + st.y = (st.y + 16) % 256 +end + +-- data/sprite_anims/objects.asm:81-92. +local OBJECTS = { + INTRO_SUICUNE = { "IntroSuicune", "IntroSuicune" }, + INTRO_PICHU = { "IntroPichu", "IntroPichuWooper" }, + INTRO_WOOPER = { "IntroWooper", "IntroPichuWooper" }, + INTRO_UNOWN = { "IntroUnown1", "IntroUnown" }, + INTRO_UNOWN_F = { "IntroUnownF", "IntroUnownF" }, + INTRO_SUICUNE_AWAY = { "IntroSuicuneAway", "IntroSuicuneAway" }, +} + +local function register(target, entries) + for name, value in pairs(entries) do + if target[name] == nil then target[name] = value end + end +end + +register(SpriteAnims.OAMSETS, OAMSETS) +register(SpriteAnims.FRAMESETS, FRAMESETS) +register(SpriteAnims.OBJECTS, OBJECTS) +register(SpriteAnims.SEQUENCES, SEQUENCES) + +-------------------------------------------------------------------------- +-- Palettes +-------------------------------------------------------------------------- + +local function scale31(value) return math.floor(value * 255 / 31 + 0.5) end + +-- CrystalIntro_UnownFade's three 32-step ramps +-- (engine/movie/intro.asm:1312-1328). +local BW_FADE, LBLUE_FADE, BLUE_FADE = {}, {}, {} +for hue = 0, 31 do + BW_FADE[hue + 1] = { scale31(hue), scale31(hue), scale31(hue) } + LBLUE_FADE[hue + 1] = { 0, scale31(math.floor(hue / 2)), scale31(hue) } + BLUE_FADE[hue + 1] = { 0, 0, scale31(hue) } +end + +local function palCopy(source) + local out = {} + for index = 1, 4 do + local color = source and source[index] + out[index] = color and { color[1], color[2], color[3] } or BLACK + end + return out +end + +local function flatPals(color) + local out = {} + for pal = 1, 8 do out[pal] = { color, color, color, color } end + return out +end + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function CrystalIntro:wantsFillScale() return true end +function CrystalIntro:drawsWidescreen() return true end + +function CrystalIntro.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CrystalIntro) + self.game = game + self.onDone = opts.onDone + local data = (game and game.data) or {} + self.assets = opts.intro or data.gen2Intro or (game and game.introData) or nil + if not (self.assets and self.assets.acts) then + Logger.warn("crystal intro: no intro.lua in the cache -- re-import " + .. "this version or the movie plays blank") + end + self.images = {} + self.sheets = {} + + self.anims = SpriteAnims.new() + self.scene = 1 + self.phase = 0 + self.hold = 0 + self.done = false + self.frames = 0 + + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + self.treeScroll, self.grassScroll = 0, 0 + self.lyActive = false + self.grassFrame = nil + + self.bgPals = flatPals(BLACK) + self.obPals = flatPals(BLACK) + self.map, self.attr = {}, {} + for index = 1, BG_TILES * BG_TILES do + self.map[index] = 0 + self.attr[index] = 0 + end + self.act = nil + self.mapDirty, self.palsDirty = true, true + return self +end + +-------------------------------------------------------------------------- +-- Scene plumbing +-------------------------------------------------------------------------- + +local function actData(self) + local acts = self.assets and self.assets.acts + return acts and self.act and acts[self.act] or nil +end + +-- ClearSpriteAnims zeroes wSpriteAnimData through wGlobalAnimXOffset +-- (engine/sprite_anims/core.asm:1-11, ram/wram.asm:202-272). +local function clearAnims(self) + self.anims:clear() + self.anims.globalX, self.anims.globalY = 0, 0 +end + +local function fades(self) + return (self.assets and self.assets.fades) or {} +end + +local function markDirty(self) + self.palsDirty = true +end + +local function loadAct(self, key) + self.act = key + self.grassFrame = nil + local act = actData(self) + for index = 1, BG_TILES * BG_TILES do + self.map[index] = act and act.tilemap and act.tilemap[index] or 0 + self.attr[index] = act and act.attrmap and act.attrmap[index] or 0 + end + local palettes = act and act.palettes or {} + for pal = 1, 8 do + self.bgPals[pal] = palCopy(palettes.bg and palettes.bg[pal]) + self.obPals[pal] = palCopy(palettes.obj and palettes.obj[pal]) + end + self.mapDirty, self.palsDirty = true, true +end + +-- Intro_ClearBGPals blacks all 16 palettes and burns two frames +-- (engine/movie/intro.asm:1554-1571); IntroScene26 clears to white instead +-- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196). +local function setup(self, white, fn) + if self.phase == 0 then + self.phase = 1 + local color = white and WHITE or BLACK + self.bgPals = flatPals(color) + self.obPals = flatPals(color) + markDirty(self) + self.hold = 1 + return + end + self.phase = 0 + fn(self) + self.scene = self.scene + 1 +end + +function CrystalIntro:playMusic(song) + local data = self.game and self.game.data + local audio = data and data.audio + if audio and audio.runtime and audio.songs and audio.songs[song] then + Music.play(data, song) + end +end + +function CrystalIntro:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if audio and audio.runtime and audio.sfx and audio.sfx[name] then + Sound.play(data, name) + end +end + +-- CrystalIntro_UnownFade (engine/movie/intro.asm:1231-1310): zero every BG +-- palette, then write the triangle-wave step of the three ramps into colours +-- 1-3 of palette `pal`. +local function unownFade(self, pal, t) + local step = t % 64 + if step > 31 then step = 63 - step end + self.bgPals = flatPals(BLACK) + local target = self.bgPals[pal + 1] + target[2] = BW_FADE[step + 1] + target[3] = LBLUE_FADE[step + 1] + target[4] = BLUE_FADE[step + 1] + markDirty(self) +end + +-- CrystalIntro_InitUnownAnim (engine/movie/intro.asm:1191-1229): four structs +-- at one spot, radii $08/$18/$28/$38, framesets 4/3/1/2. +local UNOWN_SWIRL = { + { 0x08, "IntroUnown4" }, { 0x18, "IntroUnown3" }, + { 0x28, "IntroUnown1" }, { 0x38, "IntroUnown2" }, +} + +local function initUnownAnim(self, x, y) + for _, spec in ipairs(UNOWN_SWIRL) do + local st = self.anims:init("INTRO_UNOWN", x, y) + if st then + st.var1 = spec[1] + reinit(st, spec[2]) + end + end +end + +-- Intro_ResetLYOverrides / Intro_PerspectiveScrollBG +-- (engine/movie/intro.asm:1630-1676): trees on lines 0-94 at half speed, +-- grass on lines 95-143 at double, hSCX following the tree band. +local function resetLYOverrides(self) + self.treeScroll, self.grassScroll = 0, 0 + self.lyActive = true +end + +local function perspectiveScroll(self) + if self.counter % 2 == 1 then + self.treeScroll = (self.treeScroll + 1) % 256 + end + self.grassScroll = (self.grassScroll + 2) % 256 + self.scx = self.treeScroll +end + +-- Intro_RustleGrass (engine/movie/intro.asm:1521-1547): the four tiles at +-- vTiles2 $09 cycle grass1/grass2/grass3/grass2 for the first 36 frames. +local RUSTLE = { 1, 2, 3, 2 } + +local function rustleGrass(self) + if self.counter >= 36 then return end + local frame = RUSTLE[math.floor(self.counter / 4) % 4 + 1] + if frame ~= self.grassFrame then + self.grassFrame = frame + self.mapDirty = true + end +end + +-- Intro_ColoredSuicuneFrameSwap (engine/movie/intro.asm:1500-1519): toggle +-- bit 3 of every non-zero id below $80 in the visible 20x18 window. +local function frameSwap(self) + for row = 0, 17 do + for col = 0, 19 do + local index = row * BG_TILES + col + 1 + local id = self.map[index] + if id ~= 0 and id < 0x80 then + self.map[index] = bit.bxor(id, 8) + end + end + end + self.mapDirty = true +end + +-------------------------------------------------------------------------- +-- Scenes (engine/movie/intro.asm:96-1153) +-------------------------------------------------------------------------- + +local Scenes = {} + +-- IntroScene1 (engine/movie/intro.asm:96-146). +Scenes[1] = function(self) + setup(self, false, function() + loadAct(self, "unownA") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene2 (engine/movie/intro.asm:148-170). +Scenes[2] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 3 + return + end + if a == 0x60 then + initUnownAnim(self, 11 * 8, 11 * 8) + self:playSfx("Sfx_IntroUnown1") + end + self.timer = a + unownFade(self, 0, a) +end + +-- IntroScene3 (engine/movie/intro.asm:172-218). +Scenes[3] = function(self) + setup(self, false, function() + loadAct(self, "background") + resetLYOverrides(self) + self.scx, self.scy = 0, 0 + self.counter = 0 + end) +end + +-- IntroScene4 (engine/movie/intro.asm:220-232). +Scenes[4] = function(self) + perspectiveScroll(self) + if self.counter == 0x80 then + self.scene = 5 + return + end + self.counter = (self.counter + 1) % 256 +end + +-- IntroScene5 (engine/movie/intro.asm:234-285). +Scenes[5] = function(self) + setup(self, false, function() + loadAct(self, "unownHI") + self.lyActive = false + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene6 (engine/movie/intro.asm:287-330). +Scenes[6] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 7 + return + end + if a == 0x60 then + initUnownAnim(self, 6 * 8, 14 * 8) + self:playSfx("Sfx_IntroUnown1") + self.timer = a + unownFade(self, 1, a) + return + end + if a >= 0x40 then + self.timer = a + unownFade(self, 1, a) + return + end + if a == 0x20 then + initUnownAnim(self, 15 * 8, 7 * 8) + self:playSfx("Sfx_IntroUnown2") + end + self.timer = a + unownFade(self, 0, a) +end + +-- IntroScene7 (engine/movie/intro.asm:332-401). +Scenes[7] = function(self) + setup(self, false, function() + loadAct(self, "background") + resetLYOverrides(self) + clearAnims(self) + self.anims:init("INTRO_SUICUNE", 27 * 8, 13 * 8 + 4) + self.anims.globalX = 0xf0 + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene8 (engine/movie/intro.asm:403-430). +Scenes[8] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0x40 then + self:playSfx("Sfx_IntroSuicune3") + elseif a < 0x40 then + perspectiveScroll(self) + return + end + if self.anims.globalX == 0 then + self:playSfx("Sfx_IntroSuicune2") + self.anims:clear() + self.scene = 9 + return + end + self.anims.globalX = (self.anims.globalX - 8) % 256 +end + +-- IntroScene9 (engine/movie/intro.asm:432-467): palette bands over the +-- whole map, six blocking frames. +Scenes[9] = function(self) + self.lyActive = false + for row = 0, 17 do + local pal = (row < 12 and 1) or (row < 15 and 2) or 3 + for col = 0, BG_TILES - 1 do + self.attr[row * BG_TILES + col + 1] = pal + end + end + self.anims.globalX = 0 + self.counter = 0 + self.mapDirty = true + self.hold = 6 + self.scene = 10 +end + +-- IntroScene10 (engine/movie/intro.asm:469-500). +Scenes[10] = function(self) + rustleGrass(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0xc0 then + self.scene = 11 + return + end + if a == 0x20 then + self.anims:init("INTRO_WOOPER", 6 * 8, 22 * 8) + self:playSfx("Sfx_IntroPichu") + elseif a == 0x40 then + self.anims:init("INTRO_PICHU", 16 * 8, 21 * 8 + 1) + self:playSfx("Sfx_IntroPichu") + end +end + +-- IntroScene11 (engine/movie/intro.asm:502-550). +Scenes[11] = function(self) + setup(self, false, function() + loadAct(self, "unowns") + self.lyActive = false + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene12's .UnownSounds (engine/movie/intro.asm:615-624). +local UNOWN_SOUNDS = { + [0x00] = "Sfx_IntroUnown3", [0x20] = "Sfx_IntroUnown2", + [0x40] = "Sfx_IntroUnown1", [0x60] = "Sfx_IntroUnown2", + [0x80] = "Sfx_IntroUnown3", [0x90] = "Sfx_IntroUnown2", + [0xa0] = "Sfx_IntroUnown1", [0xb0] = "Sfx_IntroUnown2", +} + +-- IntroScene12 (engine/movie/intro.asm:552-613). +Scenes[12] = function(self) + local a = self.counter + local sound = UNOWN_SOUNDS[a] + if sound then self:playSfx(sound) end + self.counter = (a + 1) % 256 + if a >= 0xc0 then + self.scene = 13 + return + end + if a < 0x80 then + self.timer = (a % 0x20) * 2 + unownFade(self, math.floor(a / 0x20), self.timer) + else + self.timer = (a % 0x10) * 4 + unownFade(self, 4 + math.floor((a - 0x80) / 0x10), self.timer) + end +end + +-- IntroScene13 (engine/movie/intro.asm:626-683). +Scenes[13] = function(self) + setup(self, false, function() + loadAct(self, "background") + clearAnims(self) + self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4) + self:playMusic(INTRO_MUSIC) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene14 (engine/movie/intro.asm:685-728). +Scenes[14] = function(self) + self.scx = (self.scx - 10) % 256 + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0x80 then + self.scene = 15 + return + end + if a == 0x60 then + self:playSfx("Sfx_IntroSuicune4") + end + if a >= 0x60 then + self.timer = 1 + local gx = self.anims.globalX + if gx < 0x88 then + self.anims:clear() + else + self.anims.globalX = (gx - 8) % 256 + end + elseif a >= 0x40 then + self.anims.globalX = (self.anims.globalX - 2) % 256 + end +end + +-- IntroScene15 (engine/movie/intro.asm:730-792). +Scenes[15] = function(self) + setup(self, false, function() + loadAct(self, "suicuneJump") + clearAnims(self) + self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8) + self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8) + self.scx, self.scy = 0, SCREEN_H + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene16 (engine/movie/intro.asm:794-810). +Scenes[16] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 17 + return + end + if a % 4 == 0 then frameSwap(self) end + if self.scy ~= 0 then + self.scy = (self.scy + 8) % 256 + end +end + +-- IntroScene17 (engine/movie/intro.asm:812-859). +Scenes[17] = function(self) + setup(self, false, function() + loadAct(self, "suicuneClose") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene18 (engine/movie/intro.asm:861-876). +Scenes[18] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x60 then + self.scene = 19 + return + end + if self.scx ~= 0x60 then + self.scx = (self.scx + 8) % 256 + end +end + +-- IntroScene19 (engine/movie/intro.asm:878-941). +Scenes[19] = function(self) + setup(self, false, function() + loadAct(self, "suicuneBack") + clearAnims(self) + self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8) + self.scx, self.scy = 0, (-5 * 8) % 256 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene20 (engine/movie/intro.asm:943-988). +Scenes[20] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x98 then + self.scene = 21 + return + end + if a >= 0x58 then return end + if a >= 0x40 then + local b = a - 0x18 + if b % 4 == 3 then + local slot = math.floor(b % 0x20 / 4) + self.timer = slot + self.bgPals[slot + 1] = palCopy(fades(self).unownAppear) + markDirty(self) + end + return + end + if a >= 0x28 then return end + self.scy = (self.scy + 1) % 256 +end + +-- IntroScene21 (engine/movie/intro.asm:990-1000). +Scenes[21] = function(self) + frameSwap(self) + self.hold = 3 + self.counter, self.timer = 0, 0 + self.scene = 22 +end + +-- IntroScene22 (engine/movie/intro.asm:1002-1012). +Scenes[22] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x8 then + self.anims:clear() + self.scene = 23 + end +end + +-- IntroScene23 (engine/movie/intro.asm:1014-1018). +Scenes[23] = function(self) + self.counter = 0 + self.scene = 24 +end + +-- IntroScene24 (engine/movie/intro.asm:1020-1042). +Scenes[24] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x20 then + self.counter = 0x40 + self.scene = 25 + return + end + if a % 4 ~= 0 then return end + local toWhite = fades(self).toWhite + local pal = toWhite and toWhite[math.floor(a % 0x20 / 4) + 1] + if not pal then return end + for slot = 1, 8 do + self.bgPals[slot] = palCopy(pal) + end + markDirty(self) +end + +-- IntroScene25 (engine/movie/intro.asm:1044-1054). +Scenes[25] = function(self) + if self.counter - 1 == 0 then + self.scene = 26 + return + end + self.counter = self.counter - 1 +end + +-- IntroScene26 (engine/movie/intro.asm:1056-1103). +Scenes[26] = function(self) + setup(self, true, function() + loadAct(self, "crystalUnowns") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene27 / Intro_FadeUnownWordPals +-- (engine/movie/intro.asm:1105-1128, :1390-1455). +Scenes[27] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 28 + self.counter = 0x80 + return + end + local t = a % 0x10 + local pal = math.floor(a % 0x80 / 0x10) + local fade = fades(self) + local target = self.bgPals[pal + 1] + if fade.wordFast and fade.wordFast[t + 1] then + target[3] = { unpack(fade.wordFast[t + 1]) } + end + if fade.wordSlow and fade.wordSlow[t + 1] then + target[4] = { unpack(fade.wordSlow[t + 1]) } + end + markDirty(self) +end + +-- IntroScene28 (engine/movie/intro.asm:1130-1153). +Scenes[28] = function(self) + local a = self.counter + if a == 0 then + self.done = true + return + end + self.counter = a - 1 + if a == 0x18 then + self.bgPals = flatPals(WHITE) + self.obPals = flatPals(WHITE) + markDirty(self) + elseif a == 0x8 then + self:playSfx("Sfx_IntroWhoosh") + end +end + +-------------------------------------------------------------------------- +-- Frame loop +-------------------------------------------------------------------------- + +-- CrystalIntro's .loop: the scene function, then PlaySpriteAnimations, then +-- DelayFrame (engine/movie/intro.asm:11-22); in-scene DelayFrames hold the +-- sprites still, so a pending `hold` skips the anim step. +function CrystalIntro:step() + if self.done then return true end + self.frames = self.frames + 1 + if self.hold > 0 then + self.hold = self.hold - 1 + return false + end + local scene = Scenes[self.scene] + if scene then scene(self) end + if self.done then return true end + if self.hold > 0 then return false end + self.anims.timer = self.timer + self.anims.counter = self.counter + self.anims:playFrame() + return self.done +end + +function CrystalIntro:enter() + if Runtime.wants("intro.boot.movie") then + Runtime.emit("intro.boot.movie", { screen = self, game = self.game }) + end +end + +function CrystalIntro:finish() + if self.finished then return end + self.finished = true + self.done = true + if Runtime.wants("intro.boot.movie_ended") then + Runtime.emit("intro.boot.movie_ended", { + screen = self, game = self.game, + skipped = self.skipped and true or false, + frames = self.frames, + }) + end + if self.onDone then self.onDone() end +end + +-- .ShutOffMusic (engine/movie/intro.asm:24-26). +function CrystalIntro:skip() + self.skipped = true + Music.stop() + self:finish() +end + +function CrystalIntro:update(_dt) + if self.finished then return end + local input = self.game and self.game.input + if input then + for _, button in ipairs({ "a", "b", "start", "select" }) do + if input:wasPressed(button) then + self:skip() + return + end + end + end + if self:step() then self:finish() end +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function CrystalIntro:image(path) + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, img = pcall(Assets.image, path) + cached = ok and img or false + if cached then + cached:setFilter("nearest", "nearest") + end + self.images[path] = cached + end + return cached or nil +end + +function CrystalIntro:sheet(path) + if not path then return nil end + local entry = self.sheets[path] + if entry ~= nil then return entry or nil end + local image = self:image(path) + if not image then + self.sheets[path] = false + return nil + end + local width, height = image:getDimensions() + local quads = {} + for tile = 0, math.floor(width / 8) * math.floor(height / 8) - 1 do + quads[tile] = love.graphics.newQuad( + tile % 16 * 8, math.floor(tile / 16) * 8, 8, 8, width, height) + end + entry = { image = image, quads = quads } + self.sheets[path] = entry + return entry +end + +-- The three layer canvases the CGB attribute byte splits the map into: +-- per-tile colour 0 (the backdrop), colours 1-3 of ordinary tiles, and +-- colours 1-3 of PRIORITY tiles, which beat every OBJ. +local function bakeLayers(self) + if not (self.mapDirty or self.palsDirty) then + return self.backdropCanvas ~= nil + end + local G = love.graphics + if not self.backdropCanvas then + local ok, a = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + local ok2, b = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + local ok3, c = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + if not (ok and ok2 and ok3) then return false end + for _, canvas in ipairs({ a, b, c }) do + canvas:setFilter("nearest", "nearest") + end + self.backdropCanvas, self.lowCanvas, self.highCanvas = a, b, c + end + local act = actData(self) + local sheet = act and self:sheet(act.tiles) + local grass = self.grassFrame + and self:sheet(self.assets and self.assets.grassFrames) + local previous = G.getCanvas() + G.push() + G.origin() + + G.setCanvas(self.backdropCanvas) + G.clear(0, 0, 0, 1) + for pal = 0, 7 do + local color = GbcPalette.color(self.bgPals[pal + 1], 1) or BLACK + G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) + for row = 0, BG_TILES - 1 do + for col = 0, BG_TILES - 1 do + if self.attr[row * BG_TILES + col + 1] % 8 == pal then + G.rectangle("fill", col * 8, row * 8, 8, 8) + end + end + end + end + G.setColor(1, 1, 1, 1) + + local shader = GbcPalette.available() + for _, layer in ipairs({ + { canvas = self.lowCanvas, prio = false }, + { canvas = self.highCanvas, prio = true }, + }) do + G.setCanvas(layer.canvas) + G.clear(0, 0, 0, 0) + if sheet then + for pal = 0, 7 do + if shader then GbcPalette.use(self.bgPals[pal + 1]) end + for row = 0, BG_TILES - 1 do + for col = 0, BG_TILES - 1 do + local index = row * BG_TILES + col + 1 + local attr = self.attr[index] + if attr % 8 == pal and (attr >= 0x80) == layer.prio then + local id = self.map[index] + local quad + local image = sheet.image + if grass and id >= 0x09 and id <= 0x0c then + quad = grass.quads[(self.grassFrame - 1) * 4 + id - 0x09] + image = grass.image + else + quad = sheet.quads[id] + end + if quad then G.draw(image, quad, col * 8, row * 8) end + end + end + end + end + if shader then GbcPalette.clear() end + end + end + G.setCanvas(previous) + G.pop() + self.mapDirty, self.palsDirty = false, false + return true +end + +-- Present a layer at (hSCX, hSCY); with the perspective bands live each +-- scanline takes the tree or grass SCX (engine/movie/intro.asm:1647-1676). +function CrystalIntro:drawLayer(canvas) + local G = love.graphics + G.setColor(1, 1, 1, 1) + if not self.lyActive then + local scx, scy = self.scx % BG_PIXELS, self.scy % BG_PIXELS + for _, ox in ipairs({ 0, BG_PIXELS }) do + for _, oy in ipairs({ 0, BG_PIXELS }) do + G.draw(canvas, -scx + ox, -scy + oy) + end + end + return + end + self.lineQuad = self.lineQuad + or love.graphics.newQuad(0, 0, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) + for line = 0, SCREEN_H - 1 do + local srcY = (self.scy + line) % BG_PIXELS + local scx = (line < 0x5f and self.treeScroll or self.grassScroll) + % BG_PIXELS + self.lineQuad:setViewport(0, srcY, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) + G.draw(canvas, self.lineQuad, -scx, line) + G.draw(canvas, self.lineQuad, -scx + BG_PIXELS, line) + end +end + +function CrystalIntro:drawObjects(priority) + local act = actData(self) + local sheet0 = act and self:sheet(act.sprites) + local sheet1 = act and act.sprites1 and self:sheet(act.sprites1) + if not (sheet0 or sheet1) then return end + local G = love.graphics + local oam = self.anims.oam + local shader = GbcPalette.available() + local current = nil + for index = #oam, 1, -1 do + local entry = oam[index] + local behind = bit.band(entry.attr, OAM_PRIO) ~= 0 + if behind == priority then + local sheet = bit.band(entry.attr, OAM_BANK1) ~= 0 and sheet1 or sheet0 + local quad = sheet and sheet.quads[entry.tile] + if quad then + local slot = entry.attr % 8 + if shader and current ~= slot then + GbcPalette.use(self.obPals[slot + 1]) + current = slot + end + local flipX = bit.band(entry.attr, OAM_XFLIP) ~= 0 + local flipY = bit.band(entry.attr, OAM_YFLIP) ~= 0 + G.setColor(1, 1, 1, 1) + G.draw(sheet.image, quad, + entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0), + 0, flipX and -1 or 1, flipY and -1 or 1) + end + end + end + if current then GbcPalette.clear() end +end + +function CrystalIntro:renderFrame() + local G = love.graphics + local baked = bakeLayers(self) + if not self.frameCanvas then + local ok, canvas = pcall(G.newCanvas, SCREEN_W, SCREEN_H) + if not ok then return nil end + canvas:setFilter("nearest", "nearest") + self.frameCanvas = canvas + end + local previous = G.getCanvas() + G.push() + G.origin() + G.setCanvas(self.frameCanvas) + G.clear(0, 0, 0, 1) + if baked then + self:drawLayer(self.backdropCanvas) + self:drawObjects(true) + self:drawLayer(self.lowCanvas) + self:drawObjects(false) + self:drawLayer(self.highCanvas) + end + G.setCanvas(previous) + G.pop() + return self.frameCanvas +end + +function CrystalIntro:drawPanel() + local G = love.graphics + local canvas = self:renderFrame() + G.setColor(1, 1, 1, 1) + if canvas then + G.draw(canvas, 0, 0) + return + end + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) +end + +function CrystalIntro:draw() + self:drawPanel() +end + +-- The widescreen surround follows the backdrop: colour 0 of the palette the +-- top-left visible tile's attribute selects, live through the fades. +function CrystalIntro:surroundColor() + local row = math.floor(self.scy / 8) % BG_TILES + local col = math.floor(self.scx / 8) % BG_TILES + local attr = self.attr[row * BG_TILES + col + 1] or 0 + return GbcPalette.color(self.bgPals[attr % 8 + 1], 1) or BLACK +end + +function CrystalIntro:drawWidescreen(winW, winH) + local G = love.graphics + local fill = self:surroundColor() + G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +CrystalIntro.Scenes = Scenes +CrystalIntro.OAMSETS = OAMSETS +CrystalIntro.FRAMESETS = FRAMESETS +CrystalIntro.OBJECTS = OBJECTS +CrystalIntro.SEQUENCES = SEQUENCES +CrystalIntro.unownFade = unownFade + +return CrystalIntro diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua index 3bc80928..a8dd55ce 100644 --- a/src/ui/gen2/Diploma.lua +++ b/src/ui/gen2/Diploma.lua @@ -2,9 +2,9 @@ -- reached through `special Diploma` after Celadon Mansion 3F's game designer -- checks VAR_DEXCAUGHT == 251). Only page 1 is ever shown in play -- -- PrintDiplomaPage2 is the Game Boy Printer's second sheet --- (engine/printer/printer.asm _PrintDiploma), stubbed separately as --- "printer: no Game Boy Printer" -- so this transcribes PlaceDiplomaOnScreen --- alone, not diploma2.asm. +-- (engine/printer/printer.asm:420), built with hBGMapMode zeroed and undone by +-- SafeLoadTempTilemapToTilemap, so it never reaches the screen on the cart +-- either -- so this transcribes PlaceDiplomaOnScreen alone, not diploma2.asm. -- -- THE CERTIFICATE IS A TILEMAP, NOT A TEXT BOX. PlaceDiplomaOnScreen -- decompresses DiplomaGFX into vTiles2 and then CopyBytes' DiplomaPage1Tilemap diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua new file mode 100644 index 00000000..07aca9b9 --- /dev/null +++ b/src/ui/gen2/GenderSelect.lua @@ -0,0 +1,139 @@ +-- ../pokecrystal/engine/menus/init_gender.asm:23-41 InitGender, which +-- PlayerProfileSetup runs before OakSpeech (engine/menus/intro_menu.asm:61-83). + +local Chrome = require("src.ui.gen2.Chrome") +local Music = require("src.core.Music") +local RomText = require("src.core.RomText") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local GenderSelect = {} +GenderSelect.__index = GenderSelect +GenderSelect.isOpaque = true + +-- .MenuData's two items (../pokecrystal/engine/menus/init_gender.asm:50-53), +-- and the wPlayerGender byte each writes (`ld a, [wMenuCursorY] / dec a`). +GenderSelect.OPTIONS = { + { label = "Boy", gender = "male" }, + { label = "Girl", gender = "female" }, +} + +-- menu_coords 6, 4, 12, 9 -- inclusive, so 7 columns by 6 rows. +local BOX_X, BOX_Y, BOX_W, BOX_H = 6, 4, 7, 6 +local TEXT_X, TEXT_Y = BOX_X + 2, BOX_Y + 2 +local CURSOR_X = TEXT_X - 1 +local ROW_STEP = 2 + +-- TEXTBOX_X / TEXTBOX_Y / TEXTBOX_INNERX / TEXTBOX_INNERY +-- (../pokecrystal/constants/text_constants.asm:25-32), the box PrintText fills. +local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4 +local SAY_TEXT_X, SAY_TEXT_Y = 1, 14 + +-- gfx/new_game/gender_screen.pal:1-4, the one palette LoadGenderScreenPal +-- writes; colour 1 is the tile the screen is filled with. +GenderSelect.GROUND = { 74, 247, 255 } + +-- `ld a, $10 / ld [wMusicFade]` with MUSIC_NONE as the fade target +-- (../pokecrystal/engine/menus/init_gender.asm:59-65). +local FADE_CONTROL = 0x10 +-- `ld c, 10 / call DelayFrames` on the way out +-- (../pokecrystal/engine/menus/init_gender.asm:39-40). +local EXIT_FRAMES = 10 + +local FALLBACK = Strings.source("Are you a boy?\nOr are you a girl?") + +function GenderSelect:wantsFillScale() return true end +function GenderSelect:drawsWidescreen() return true end + +-- opts: onDone(gender), save +function GenderSelect.new(game, opts) + opts = opts or {} + local self = setmetatable({}, GenderSelect) + self.game = game + self.save = opts.save or (game and game.save) + self.onDone = opts.onDone + self.data = (game and game.data) or {} + -- `db 1 ; default option`: the cursor opens on Boy. + self.cursor = 1 + self.exit = nil + self.text = RomText(self.data, "_AreYouABoyOrAreYouAGirlText", + FALLBACK) + return self +end + +function GenderSelect:enter() + Music.fadeOut(FADE_CONTROL) +end + +function GenderSelect:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +function GenderSelect:choose(index) + local option = GenderSelect.OPTIONS[index] or GenderSelect.OPTIONS[1] + if self.save and self.save.player then + self.save.player.gender = option.gender + end + self.chosen = option.gender + self.exit = EXIT_FRAMES +end + +function GenderSelect:update(_dt) + if self.exit then + self.exit = self.exit - 1 + if self.exit > 0 then return end + self.exit = nil + if self.onDone then self.onDone(self.chosen) end + return + end + local input = self.game and self.game.input + if not input then return end + -- STATICMENU_WRAP, and STATICMENU_DISABLE_B: no `b` arm at all. + if input:wasPressed("up") then + self.cursor = self.cursor > 1 and self.cursor - 1 or #GenderSelect.OPTIONS + elseif input:wasPressed("down") then + self.cursor = self.cursor < #GenderSelect.OPTIONS and self.cursor + 1 or 1 + elseif input:wasPressed("a") or input:wasPressed("start") then + -- MenuClickSound (../pokecrystal/home/menu.asm:793-803). + self:playSfx("Sfx_ReadText2") + self:choose(self.cursor) + end +end + +function GenderSelect:drawPanel() + local G = love.graphics + local ground = GenderSelect.GROUND + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + G.setColor(1, 1, 1, 1) + Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) + Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1) + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + for i, option in ipairs(GenderSelect.OPTIONS) do + local row = TEXT_Y + (i - 1) * ROW_STEP + Chrome.print(option.label, TEXT_X, row) + if i == self.cursor then Chrome.cursor(CURSOR_X, row) end + end +end + +function GenderSelect:draw() + self:drawPanel() +end + +function GenderSelect:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return GenderSelect diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 23ee87ce..d1940f67 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -31,10 +31,11 @@ -- can assert them without a graphics device (the same shape -- src/ui/gen2/SummaryMenu.lua uses). -- --- WHAT THE CACHE DOES NOT HAVE. GetTrainerPic for TRAINER_CLASS CAL -- the --- 7x7 picture of the player HOF_AnimatePlayerPic ends on -- is not extracted. --- The player's own 5x7 portrait from the trainer card is, and it is the same --- character, so that stands in and is padded into the 7x7 block the way +-- WHAT THE CACHE MAY NOT HAVE. Gold's HOF_AnimatePlayerPic ends on +-- GetTrainerPic for TRAINER_CLASS CAL, which is not extracted; Crystal's +-- HOF_LoadTrainerFrontpic ends on ChrisPic / KrisPic, which are extracted when +-- the cache is new enough. Failing both, the player's own 5x7 portrait from +-- the trainer card stands in and is padded into the 7x7 block the way -- PlaceGraphic would. ProfOaksPCRating, which the cart prints into the bottom -- box afterwards, needs Oak's PC (still a stub in src/script/gen2/Specials.lua) -- and so the box is drawn empty, exactly as it is before that farcall. @@ -45,6 +46,7 @@ local CommonText = require("src.core.gen2.CommonText") local Core = require("src.core.gen2.HallOfFame") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") local Sound = require("src.core.Sound") @@ -250,15 +252,26 @@ function HallOfFame.new(game, opts) -- The player's own two pictures. The backpic is a plain image (the battle -- HUD's), the front is the trainer card's 5x7 portrait tile sheet. local menuGfx = data.gen2MenuGfx or {} + -- HOF_AnimatePlayerPic calls GetPlayerBackpic (../pokecrystal/engine/events/ + -- halloffame.asm:520-530, ../pokecrystal/engine/gfx/player_gfx.asm:123-128). + local female = Gen2Save.isFemale(self.save) + local hud = menuGfx.battleHud or {} -- player.sprite, the same hook Gen 1's Hall of Fame raises through -- Sprites.playerPath (src/ui/HallOfFame.lua:100). self.playerBackPath = require("src.pokemon.Sprites").playerPic( - (menuGfx.battleHud or {}).playerBack, + (female and hud.playerBackFemale) or hud.playerBack, { side = "back", kind = "hof", data = data }) + -- HOF_LoadTrainerFrontpic's ChrisPic / KrisPic under class CHRIS / KRIS + -- (../pokecrystal/engine/gfx/player_gfx.asm:138-168). + local pics = hud.trainerPics or {} + self.trainerPicPath = (female and pics.KRIS) or pics.CHRIS local card = menuGfx.trainerCard if card and card.card then + -- GetCardPic's KrisCardPic arm + -- (../pokecrystal/engine/gfx/player_gfx.asm:96-101). self.portrait = TileSheet.new({ - path = card.card, wide = card.cardTilesWide or 16, firstTile = 0, + path = (female and card.cardFemale) or card.card, + wide = card.cardTilesWide or 16, firstTile = 0, }) self.portraitWide = card.portraitWide or 5 self.portraitTiles = card.portraitTiles or 35 @@ -559,6 +572,8 @@ end -- run of tile ids TrainerCard_PrintTopHalfOfCard uses, translated by the -- scroll rather than drawn at a tile coordinate. function HallOfFame:drawPortrait(tileX, tileY) + local pic = self:image(self.trainerPicPath) + if pic then return self:drawScrolled(pic, tileX, tileY, nil) end if not (self.portrait and self.portrait:available()) then return end local G = love.graphics local wide = self.portraitWide diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index 5a3f6a0f..f9f40830 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -33,6 +33,7 @@ local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") local GbcPalette = require("src.render.GbcPalette") local MagnetTrain = require("src.core.gen2.MagnetTrain") local Music = require("src.core.Music") @@ -82,6 +83,8 @@ function MagnetTrainRide.new(game, opts) self.data = game and game.data self.onDone = opts.onDone self.finished = false + local save = opts.save or (game and game.save) + self.gender = save and save.player and save.player.gender or nil local field = self.data and self.data.gen2Field local gfx = field and field.magnetTrain @@ -155,8 +158,7 @@ end -- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because -- the OAM data addresses the four tiles of a frame individually. function MagnetTrainRide:playerSheet() - local sprites = self.data and self.data.gen2Sprites - local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS) + local def = self:playerSpriteDef() local path = def and def.image if not path then return nil end local ok, image = pcall(Assets.image, path) @@ -357,14 +359,23 @@ function MagnetTrainRide:drawBands(canvas) end end +-- GetPlayerIcon's sheet, which .InitPlayerSpriteAnim pairs with +-- SPRITE_ANIM_OBJ_MAGNET_TRAIN_RED or _BLUE +-- (../pokecrystal/engine/events/magnet_train.asm:131-141, :291-305). +function MagnetTrainRide:playerSpriteDef() + local sprites = self.data and self.data.gen2Sprites + if not sprites then return nil end + return sprites[FieldMoves.playerSprite(self.gender)] + or sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS +end + -- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry --- names. +-- names, or PAL_OW_BLUE for the _BLUE object Kris rides under. function MagnetTrainRide:playerPalette() local palettes = self.data and self.data.gen2Palettes - local sprites = self.data and self.data.gen2Sprites if not palettes then return nil end return Palettes.spritePalette(palettes, Palettes.clockDaytime(), - sprites and sprites.SPRITE_CHRIS) + self:playerSpriteDef()) end function MagnetTrainRide:drawPlayer() diff --git a/src/ui/gen2/MoveTutor.lua b/src/ui/gen2/MoveTutor.lua new file mode 100644 index 00000000..ad6f0dc4 --- /dev/null +++ b/src/ui/gen2/MoveTutor.lua @@ -0,0 +1,163 @@ +-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor, the submenu +-- FadeToMenu / ChooseMonToLearnTMHM / CloseSubmenu wraps, and :54 +-- CheckCanLearnMoveTutorMove, whose refusals print into a Textbox over the +-- party list (:101-103 `menu_coords 0, 12, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1`). +-- +-- ChooseMonToLearnTMHM (../pokecrystal/engine/items/tmhm.asm:73) is +-- InitPartyMenuWithCancel with PARTYMENUACTION_TEACH_TMHM, which is the list +-- src/ui/gen2/PartyMenu.lua already draws when it is handed `tmhm`. + +local Chrome = require("src.ui.gen2.Chrome") +local Happiness = require("src.core.gen2.Happiness") +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local Strings = require("src.core.Strings") + +local MoveTutor = {} +MoveTutor.__index = MoveTutor +MoveTutor.isOpaque = true + +-- ../pokecrystal/data/text/common_2.asm:187 _TMHMNotCompatibleText and +-- common_3.asm:1424 _KnowsMoveText. CopyName1 leaves the MOVE in +-- wStringBuffer2 and GetNickname the nickname in wStringBuffer1. +local NOT_COMPATIBLE = Strings.source("%s is\nnot compatible\vwith %s.") +local KNOWS_MOVE = Strings.source("%s knows\n%s.") + +-- CanLearnTMHMMove's bitfield: `add_mt` gives the three tutor moves TMHM +-- flags 58-60 (../pokecrystal/constants/item_constants.asm:295-307), which +-- the cache splits out as `tutorMoves`. +function MoveTutor.canLearn(species, moveId) + if type(species) ~= "table" or not moveId then return false end + for _, id in ipairs(species.tmhm or {}) do + if id == moveId then return true end + end + for _, id in ipairs(species.tutorMoves or {}) do + if id == moveId then return true end + end + return false +end + +-- The same bitfield as a pokemon.lua view, for PartyMenu's ABLE / NOT ABLE +-- column (src/ui/gen2/PartyMenu.lua:735, which walks `tmhm` only). +function MoveTutor.speciesView(pokemon) + local cache = {} + return setmetatable({}, { __index = function(_, key) + local hit = cache[key] + if hit ~= nil then return hit or nil end + local def = pokemon and pokemon[key] + if type(def) ~= "table" then + cache[key] = false + return nil + end + local merged = {} + for field, value in pairs(def) do merged[field] = value end + local list = {} + for _, id in ipairs(def.tmhm or {}) do list[#list + 1] = id end + for _, id in ipairs(def.tutorMoves or {}) do list[#list + 1] = id end + merged.tmhm = list + cache[key] = merged + return merged + end }) +end + +-- opts: move, moveName, onDone(learned) +function MoveTutor.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MoveTutor) + self.game = game + local data = (game and game.data) or {} + self.move = opts.move + self.moveName = opts.moveName or opts.move or "?" + self.onDone = opts.onDone + self.pokemon = opts.pokemon or data.pokemon + self.view = MoveTutor.speciesView(self.pokemon) + self.list = self:buildList() + return self +end + +function MoveTutor:wantsFillScale() return true end +function MoveTutor:drawsWidescreen() return true end + +function MoveTutor:buildList() + return PartyMenu.new(self.game, { + prompt = "teach", + tmhm = { move = self.move }, + pokemon = self.view, + onChoose = function(_, mon) self:picked(mon) end, + onCancel = function() self:finish(false) end, + }) +end + +function MoveTutor:say(body, onDone) + if self.game and self.game.say then return self.game:say(body, onDone) end + if onDone then onDone() end +end + +function MoveTutor:playSfx(name) + local world = self.game and self.game.world + if world and world.playSfxNamed then world:playSfxNamed(name) end +end + +-- CheckCanLearnMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:54); +-- all three failures fall back into the list, `jr nc, .loop` at :24. +function MoveTutor:picked(mon) + if not mon then return end + local name = Mon.displayName(mon) + if not MoveTutor.canLearn(self.view[mon.species], self.move) then + self:playSfx("Sfx_Wrong") + return self:say(Strings(NOT_COMPATIBLE, self.moveName, name)) + end + -- ../pokecrystal/engine/pokemon/knows_move.asm:16 sets carry and prints, + -- which is the same .didnt_learn arm. + for _, move in ipairs(mon.moves or {}) do + if move.id == self.move then + return self:say(Strings(KNOWS_MOVE, name, self.moveName)) + end + end + local game = self.game + if not (game and game.learnMoveOn) then return self:finish(false) end + game:learnMoveOn(mon, self.move, function(learned) + if not learned then return end + -- :87-88 `ld c, HAPPINESS_LEARNMOVE / callfar ChangeHappiness`. The 4000 + -- coins are the script's own takecoins (maps/GoldenrodCity.asm:124). + Happiness.change(mon, "LEARNMOVE") + self:finish(true) + end) +end + +function MoveTutor:finish(learned) + if self.done then return end + self.done = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone(learned) end +end + +function MoveTutor:update(dt) + if self.done then return end + self.list:update(dt) +end + +function MoveTutor:drawPanel() + self.list:drawPanel() +end + +function MoveTutor:draw() + self.list:draw() +end + +-- PartyMenu:drawWidescreen's own blit: the party page is white to the window +-- edge, at the one integer scale Chrome.fitScale picks. +function MoveTutor:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MoveTutor diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index 3f2dbee6..dec13836 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -34,13 +34,25 @@ local NamePick = {} NamePick.__index = NamePick NamePick.isOpaque = true --- data/player_names.asm PlayerNameArray, one half of the IF per edition. -local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" } -local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" } +-- data/player_names.asm PlayerNameArray, one half of the IF per edition, and +-- Crystal's two arrays at ../pokecrystal/data/player_names.asm:12-16, :31-35. +local PRESETS = { + gold = { "GOLD", "HIRO", "TAYLOR", "KARL" }, + silver = { "SILVER", "KAMON", "OSCAR", "MAX" }, + crystal = { "CHRIS", "MAT", "ALLAN", "JON" }, +} +local PRESETS_FEMALE = { + crystal = { "KRIS", "AMANDA", "JUANA", "JODI" }, +} -local function presetsFor() - local silver = require("src.core.GameVersion").get() == "silver" - return silver and PRESETS_SILVER or PRESETS +-- ShowPlayerNamingChoices picks the header off wPlayerGender +-- (../pokecrystal/engine/gfx/player_gfx.asm:57-62). +local function presetsFor(gender) + local version = require("src.core.GameVersion").get() + if gender == "female" and PRESETS_FEMALE[version] then + return PRESETS_FEMALE[version] + end + return PRESETS[version] or PRESETS.gold end -- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12). @@ -59,14 +71,16 @@ function NamePick:wantsFillScale() return true end function NamePick:drawsWidescreen() return true end -- opts: onDone(name), font, pic (the CAL frontpic already loaded by the Oak --- speech), picColors, presets +-- speech), picColors, presets, gender function NamePick.new(game, opts) opts = opts or {} local self = setmetatable({}, NamePick) self.game = game self.onDone = opts.onDone + self.gender = opts.gender + or (game and game.save and game.save.player and game.save.player.gender) self.items = { "NEW NAME" } - for _, name in ipairs(opts.presets or presetsFor()) do + for _, name in ipairs(opts.presets or presetsFor(self.gender)) do self.items[#self.items + 1] = name end -- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset. @@ -96,15 +110,19 @@ end function NamePick:openNaming() local data = self.game and self.game.data or {} local sprites = data.gen2Sprites - local chris = sprites and sprites.SPRITE_CHRIS + local NamingScreen = require("src.ui.gen2.NamingScreen") + local def = sprites and sprites[NamingScreen.playerSprite(self.gender)] local Palettes = require("src.world.gen2.Palettes") Screens.push(self.game, "Gen2NamingScreen", { type = "player", + gender = self.gender, menuGfx = data.gen2MenuGfx, - iconPath = chris and chris.image or nil, - -- Chris is PAL_OW_RED; the naming screen is lit like day. + iconPath = def and def.image or nil, + -- Chris is PAL_OW_RED and Kris PAL_OW_BLUE + -- (../pokecrystal/engine/overworld/player_object.asm:32-39); the naming + -- screen is lit like day. iconColors = data.gen2Palettes - and Palettes.spritePalette(data.gen2Palettes, "DAY", chris) or nil, + and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil, onDone = function(name) -- An empty name keeps the default, the way ending entry with nothing -- typed leaves wPlayerName at its preset. @@ -112,7 +130,7 @@ function NamePick:openNaming() if name and #name > 0 then self:choose(name) else - self:choose(self.items[2] or presetsFor()[1]) + self:choose(self.items[2] or presetsFor(self.gender)[1]) end end, }) @@ -152,7 +170,7 @@ function NamePick:update(_dt) if self.fontOk then self:openNaming() else - self:choose(self.items[2] or presetsFor()[1]) + self:choose(self.items[2] or presetsFor(self.gender)[1]) end else -- A preset returns through MovePlayerPicLeft, so the pic walks back @@ -232,7 +250,7 @@ function NamePick:drawWidescreen(winW, winH) end NamePick.PRESETS = PRESETS -NamePick.PRESETS_SILVER = PRESETS_SILVER +NamePick.PRESETS_FEMALE = PRESETS_FEMALE NamePick.presetsFor = presetsFor return NamePick diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 4857499c..7107e296 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -81,19 +81,28 @@ local BOTTOM_CURSOR_TILES = 5 -- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes. -- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters. NamingScreen.TYPES = { - player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS" }, + player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS", + spriteFemale = "SPRITE_KRIS" }, rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" }, mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" }, box = { prompt = "BOX NAME?", maxLength = 8, isBox = true }, nickname = { prompt = nil, maxLength = 10 }, } +-- GetPlayerIcon's two sheets (../pokecrystal/engine/gfx/player_gfx.asm:85-93), +-- which is also the pair the header icon here is cut from. +function NamingScreen.playerSprite(gender) + local kind = NamingScreen.TYPES.player + if gender == "female" and kind.spriteFemale then return kind.spriteFemale end + return kind.sprite +end + function NamingScreen:wantsFillScale() return true end function NamingScreen:drawsWidescreen() return true end -- opts: type ("player"/"rival"/"mom"/"box"/"nickname"), prompt, maxLength, --- initial, monName (nickname header), icon/sprite image path, onDone(name), --- onCancel(). +-- initial, monName (nickname header), icon/sprite image path, gender, +-- onDone(name), onCancel(). function NamingScreen.new(game, opts) opts = opts or {} local self = setmetatable({}, NamingScreen) @@ -101,6 +110,8 @@ function NamingScreen.new(game, opts) local kind = NamingScreen.TYPES[opts.type or "player"] or NamingScreen.TYPES.player self.kind = kind + self.gender = opts.gender + or (game and game.save and game.save.player and game.save.player.gender) self.isBox = opts.isBox or kind.isBox or false self.maxLength = opts.maxLength or kind.maxLength or 7 self.prompt = opts.prompt or kind.prompt or "NICKNAME?" diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index a68f6ebb..230693a1 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -37,7 +37,9 @@ -- love.graphics.newImage skips overrides/ and AssetTransform output. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") local Font = require("src.render.Font") +local Gen2Save = require("src.core.gen2.Save") local GbcPalette = require("src.render.GbcPalette") local Logger = require("src.core.Logger") local Music = require("src.core.Music") @@ -98,6 +100,11 @@ function OakSpeech.new(game, opts) self.playerPic = tryImage(require("src.pokemon.Sprites").playerPic( data.playerPic or "assets/generated/intro/cal.png", { side = "front", kind = "intro", data = game and game.data })) + -- DrawIntroPlayerPic's other arm, KrisPic under trainer class KRIS + -- (../pokecrystal/engine/gfx/player_gfx.asm:170-188). + self.playerPicFemale = tryImage(require("src.pokemon.Sprites").playerPic( + data.playerPicFemale or "assets/generated/intro/kris.png", + { side = "front", kind = "intro", data = game and game.data })) self.marillPic = tryImage(data.marillPic or "assets/generated/battle/front/marill.png") self.shrinkPic1 = tryImage(data.shrink1 or "assets/generated/intro/shrink1.png") @@ -114,6 +121,9 @@ function OakSpeech.new(game, opts) self.palettes = palettes self.oakColors = Palettes.trainerColors(palettes, "POKEMON_PROF") self.playerColors = Palettes.trainerColors(palettes, "CAL") + -- KrisPalette is Falkner's row, shared rather than shipped + -- (../pokecrystal/data/trainers/palettes.asm:11-12). + self.playerColorsFemale = Palettes.trainerColors(palettes, "FALKNER") self.marillColors = Palettes.monColors(palettes, self.demoSpecies) self.picColors = nil self.fontOk = false @@ -134,6 +144,23 @@ function OakSpeech.new(game, opts) return self end +-- wPlayerGender, the byte the gender step below writes +-- (../pokecrystal/engine/menus/init_gender.asm:36-38). +function OakSpeech:gender() + local save = self.game and self.game.save + return save and save.player and save.player.gender or nil +end + +-- DrawIntroPlayerPic reads the byte every time it runs, so the pic follows the +-- answer rather than whatever was loaded when the speech opened +-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188). +function OakSpeech:playerPicNow() + if self:gender() == "female" and self.playerPicFemale then + return self.playerPicFemale, self.playerColorsFemale or self.playerColors + end + return self.playerPic, self.playerColors +end + function OakSpeech:text(key) local t = self.texts[key] if type(t) == "string" and #t > 0 then return t end @@ -144,8 +171,8 @@ end -- The vanilla beat list. Ids are the stable anchors a build wrapper inserts -- around; see the header for which of them are shared with Gen 1. -function OakSpeech.defaultSteps(_speech) - return { +function OakSpeech.defaultSteps(speech) + local steps = { -- `farcall InitClock` is the first line of OakSpeech. { id = "init_clock", kind = "initclock" }, -- Intro_PrepTrainerPic POKEMON_PROF, FadeInIntroPic, OakText1. @@ -170,6 +197,14 @@ function OakSpeech.defaultSteps(_speech) { id = "legend", kind = "say", textKey = "_OakText7", pic = "player" }, { id = "shrink", kind = "shrink", textKey = "_OakText7" }, } + -- PlayerProfileSetup's `farcall InitGender` runs before OakSpeech is called. + -- ../pokecrystal/engine/menus/intro_menu.asm:61-67, :79-83 + local data = speech and speech.game and speech.game.data + if FieldMoves.hasGenderChoice(data and data.gen2Sprites) then + table.insert(steps, 1, { id = "gender_select", kind = "gender", + saveKey = "gender" }) + end + return steps end local function sameSteps(steps) return steps end @@ -190,14 +225,24 @@ function OakSpeech:buildSteps() return hooked end -function OakSpeech:enter() +-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade. +-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65 +function OakSpeech:startMusic() + if self.musicStarted then return end + self.musicStarted = true local data = self.game and self.game.data if data and data.audio and data.audio.runtime then Music.play(data, self.music, true, { reason = "oak_speech" }) end +end + +function OakSpeech:enter() self.step = 0 self.answers = {} self.steps = self:buildSteps() + if not (self.steps[1] and self.steps[1].kind == "gender") then + self:startMusic() + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -211,7 +256,7 @@ end -- same way every pic on this screen does (two shipped colours, bracketed). function OakSpeech:resolvePic(desc) if desc == "oak" then return self.oakPic, self.oakColors end - if desc == "player" then return self.playerPic, self.playerColors end + if desc == "player" then return self:playerPicNow() end if desc == "demo" then return self.marillPic, self.marillColors end if type(desc) ~= "table" then return nil, nil end if desc.type == "pokemon" then @@ -339,21 +384,59 @@ function OakSpeech:openInitClock() end end +-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech +-- says anything and before its music starts +-- (../pokecrystal/engine/menus/intro_menu.asm:79-83). +function OakSpeech:openGenderSelect(step) + local game = self.game + if not (game and game.stack) then + self:startMusic() + return self:advance() + end + self.busy = true + local pushed = Screens.push(game, "Gen2GenderSelect", { + save = game.save, + onDone = function(gender) + game.stack:pop() + self.busy = false + -- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default + -- NamePlayer lays down before the menu opens + -- (../pokecrystal/engine/menus/intro_menu.asm:768-781). + local save = game.save + if save and save.player then + save.player.name = Gen2Save.defaultPlayerName(save.version, gender) + end + self:recordAnswer(step, gender == "female" and 2 or 1, + gender == "female" and "Girl" or "Boy", gender) + self:startMusic() + self:advance() + end, + }) + if not pushed then + self.busy = false + self:startMusic() + self:advance() + end +end + function OakSpeech:openNamePick(step) self.busy = true local NamePick = require("src.ui.gen2.NamePick") + local gender = self:gender() + local pic, picColors = self:playerPicNow() Screens.push(self.game, "Gen2NamePick", { font = self.game.fontData, + gender = gender, -- NamePlayer opens with MovePlayerPicRight, so the name menu owns the -- pic while it is up: it is the same CAL frontpic this speech has been -- showing, walked over to make room for the box. - pic = self.playerPic, - picColors = self.playerColors, + pic = pic, + picColors = picColors, presets = step.presets or namePresets(self.game, step.presetsWho or step.who or "player", - step.presetsFallback or NamePick.presetsFor()), + step.presetsFallback or NamePick.presetsFor(gender)), onDone = function(name) - name = name or NamePick.presetsFor()[1] + name = name or NamePick.presetsFor(gender)[1] self.game.save.player.name = name self.game.stack:pop() -- NamePick self.busy = false @@ -450,7 +533,9 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "initclock" then + if kind == "gender" then + self:openGenderSelect(step) + elseif kind == "initclock" then self:openInitClock() elseif kind == "say" then self:applyPic(step) diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 5217f3ca..432ad490 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -12,6 +12,7 @@ local Bag = require("src.inventory.Bag") local Chrome = require("src.ui.gen2.Chrome") +local Gen2Save = require("src.core.gen2.Save") local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -78,6 +79,21 @@ local ASK_ITEM_MOVE = { "Where should this", "be moved to?" } local NO_POKEMON = { "You don't have a", "#MON!" } local EGG_CANT_HOLD = { "An EGG can't hold", "an item." } +-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it +-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786). +local function packGfxFor(menuGfx, save, tutorial) + local pack = menuGfx and menuGfx.pack + if not (pack and pack.palettesFemale) then return menuGfx end + if tutorial or not Gen2Save.isFemale(save) then return menuGfx end + local female = {} + for key, value in pairs(pack) do female[key] = value end + female.palettes = pack.palettesFemale + local out = {} + for key, value in pairs(menuGfx) do out[key] = value end + out.pack = female + return out +end + -- The PACK's cursor bytes. Every pocket menu restores its own cursor and -- scroll before ScrollingMenu and writes them back after -- `ld a, -- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY] @@ -157,7 +173,8 @@ function PackMenu.new(game, opts) end self:restoreCursor() -- The cart's own PACK tiles, when the cache has them. - self.gfx = PackGfx.new(game and game.data and game.data.gen2MenuGfx) + self.gfx = PackGfx.new(packGfxFor( + game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial)) self:rebuild() return self end diff --git a/src/ui/gen2/PhotoStudio.lua b/src/ui/gen2/PhotoStudio.lua index 2be2d785..d719e709 100644 --- a/src/ui/gen2/PhotoStudio.lua +++ b/src/ui/gen2/PhotoStudio.lua @@ -9,7 +9,7 @@ -- then a second card (PrintPartyMonPage2, the mon's other three moves and -- its non-HP stats) goes out behind it. There is no Game Boy Printer here -- -- same reason src/ui/gen2/UnownPrinter.lua takes its A press nowhere and --- PrintDiploma is stubbed in Specials.lua +-- PrintDiploma's own print goes nowhere -- -- so only page 1 is worth transcribing: it is the portrait itself, the -- part a player actually watches happen, while page 2 only ever existed as -- ink on a strip of thermal paper nobody in this port owns. H.PhotoStudio diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 4d94027e..9c1dfdbe 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -21,6 +21,8 @@ -- itself at (0,0) from $46. local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") local Font = require("src.render.Font") local Palettes = require("src.world.gen2.Palettes") @@ -56,6 +58,10 @@ local CARDS = { -- card over. One row, so `#self.cards` stays 1 and nothing pages. local FLY_MAP_CARD = { id = "map", label = "FLY" } +-- ../pokecrystal/engine/events/specials.asm:102 OverworldTownMap -> _TownMap +-- (:1757): the wall map and the DECO_TOWN_MAP poster, no strip and no card gate. +local TOWN_MAP_CARD = { id = "map", label = "MAP" } + -- ---------------------------------------------------------------- the radio -- -- engine/pokegear/radio.asm is not a text table: it is a jumptable of code. @@ -877,7 +883,14 @@ function Pokegear.new(game, opts) self.save = opts.save or (game and game.save) local data = game and game.data or {} self.landmarks = opts.landmarks or data.gen2Landmarks + -- TownMap_GetCurrentLandmark (../pokecrystal/engine/pokegear/pokegear.asm:1783) + -- reads the map header itself, so a caller that has no landmark to hand still + -- gets one. self.currentLandmark = opts.currentLandmark + if self.currentLandmark == nil and game and game.currentLandmark then + local ok, id = pcall(game.currentLandmark, game) + if ok then self.currentLandmark = id end + end self.clock = opts.clock self.onClose = opts.onClose self.cards = self:visibleCards() @@ -930,6 +943,15 @@ function Pokegear.new(game, opts) self.flyIndex = (self:region() == "kanto") and #self.fly or 1 end + -- _TownMap (../pokecrystal/engine/pokegear/pokegear.asm:1757): the same map, + -- one card, no ENGINE_MAP_CARD test and no strip to page. + self.townMap = (not self.fly) and opts.townMap and true or nil + if self.townMap then + self.cards = { TOWN_MAP_CARD } + self.cardIndex = 1 + self.mode = "card" + end + -- _CGB_PokegearPals writes wBGPals1 only (engine/gfx/cgb_layouts.asm:157), -- so the RED_WALK icon keeps the overworld's own OBJ palette. self.sprites = opts.sprites or data.gen2Sprites @@ -937,6 +959,10 @@ function Pokegear.new(game, opts) local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear self.gfx = gfx + -- _CGB_PokegearPals picks FemalePokegearPals off wPlayerGender + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190). + self.gearPals = gfx and ((Gen2Save.isFemale(self.save) + and gfx.palettesFemale) or gfx.palettes) or nil if gfx then self.sheet = TileSheet.new({ path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, @@ -950,19 +976,25 @@ function Pokegear:styled() return self.sheet ~= nil and self.sheet:available() end +-- MalePokegearPals or FemalePokegearPals, whichever _CGB_PokegearPals would +-- have copied into wBGPals1 (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190). +function Pokegear:pals() + return self.gearPals +end + -- TownMapPals: a nybble per tile id for $00..$5f, palette 0 above that. function Pokegear:colorsFor(tile) - local gfx = self.gfx - if not (gfx and gfx.palettes) then return nil end - if tile >= 0x60 then return gfx.palettes[1] end - return gfx.palettes[(gfx.palMap and gfx.palMap[tile + 1]) or 1] + local pals = self:pals() + if not pals then return nil end + if tile >= 0x60 then return pals[1] end + return pals[(self.gfx.palMap and self.gfx.palMap[tile + 1]) or 1] end -- Every string on a Pokegear card is a run of font tiles laid straight into -- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather -- than drawing as black ink over whatever was underneath. function Pokegear:text(str, tx, ty) - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() return Chrome.printThrough(str, tx, ty, pals and pals[1]) end @@ -1079,6 +1111,7 @@ function Pokegear:update(_dt) -- The fly picker owns the whole screen: no strip, no card paging, and B -- answers -1 rather than backing out to the strip. if self.fly then return self:updateFlyMap(input) end + if self.townMap then return self:updateTownMap(input) end if self.mode == "strip" then local stripCard = self:card() if not (stripCard and stripCard.id == "phone") then @@ -1160,8 +1193,9 @@ function Pokegear:region() and landmarks[self.currentLandmark] local index = current and current.index or 0 -- LANDMARK_FAST_SHIP is $5e = 94, past every Kanto landmark and still Johto. - if index == 94 then return "johto" end - if index >= 46 then return "kanto" end + if index == self:landmarkIndex("FAST_SHIP", 0x5e) then return "johto" end + -- `cp KANTO_LANDMARK`, which is PALLET_TOWN's own index. + if index >= self:landmarkIndex("PALLET_TOWN", 0x2e) then return "kanto" end return "johto" end @@ -1605,18 +1639,31 @@ local LANDMARK_PALLET_TOWN = 0x2e local LANDMARK_VICTORY_ROAD = 0x57 local LANDMARK_ROUTE_28 = 0x5d +-- ../pokecrystal/constants/landmark_constants.asm:34 inserts BATTLE_TOWER, so +-- every index above it is one higher than pokegold's; the cache's own record is +-- the authority and the numbers above are the fallback for a dataset without one. +function Pokegear:landmarkIndex(id, fallback) + local records = (self.landmarks or {}).landmarks + local record = records and (records["LANDMARK_" .. id] or records[id]) + local index = record and tonumber(record.index) + return index or fallback +end + -- Returns d (last) and e (first). Kanto's pair comes from -- TownMap_GetKantoLandmarkLimits, which withholds everything west of Victory -- Road until the Hall of Fame is on the record -- before that the Kanto map -- only walks the seven landmarks on the road to Indigo Plateau. function Pokegear:cursorLimits() if self:region() ~= "kanto" then - return LANDMARK_SILVER_CAVE, LANDMARK_NEW_BARK_TOWN + -- `ld d, KANTO_LANDMARK - 1`, which is SILVER_CAVE either way round. + return self:landmarkIndex("SILVER_CAVE", LANDMARK_SILVER_CAVE), + self:landmarkIndex("NEW_BARK_TOWN", LANDMARK_NEW_BARK_TOWN) end + local last = self:landmarkIndex("ROUTE_28", LANDMARK_ROUTE_28) if ((self.save or {}).flags or {}).HALL_OF_FAME then - return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN + return last, self:landmarkIndex("PALLET_TOWN", LANDMARK_PALLET_TOWN) end - return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD + return last, self:landmarkIndex("VICTORY_ROAD", LANDMARK_VICTORY_ROAD) end -- The cursor's landmark index. Unset, it is the player's own, which is what @@ -1637,32 +1684,59 @@ end -- PokegearMap_ContinueMap's .DPad. Both branches share the increment or -- decrement that follows them, which is why the wrap writes e - 1 / d + 1 --- rather than e / d. -function Pokegear:moveMapCursor(input) +-- rather than e / d. _TownMap's own .pressed_up / .pressed_down +-- (../pokecrystal/engine/pokegear/pokegear.asm:1853-1877) are the same pair of +-- wraps against the same d/e, so the poster steps through here too. +function Pokegear:stepMapCursor(delta) local last, first = self:cursorLimits() local cursor = self:mapCursorIndex() - if input:wasPressed("up") then + if delta > 0 then -- `cp d / jr c, .wrap_around_up`: below the last landmark the value is -- left alone, at or past it the cursor is slammed to e - 1 first. if cursor >= last then cursor = first - 1 end cursor = cursor + 1 - elseif input:wasPressed("down") then + else -- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps. if cursor == first then cursor = last + 1 end cursor = cursor - 1 + end + self.mapCursor = cursor +end + +function Pokegear:moveMapCursor(input) + if input:wasPressed("up") then + self:stepMapCursor(1) + elseif input:wasPressed("down") then + self:stepMapCursor(-1) elseif input:wasPressed("right") then -- Left and right do not move the cursor at all on this card: they page -- the POKeGEAR. .right takes the PHONE if it is owned and the RADIO if -- it is not; .left always takes the CLOCK. self:switchCard("phone", "radio") - return elseif input:wasPressed("left") then self:switchCard("clock") - return - else + end +end + +-- --------------------------------------------------------------- town map +-- +-- _TownMap's `.loop` (../pokecrystal/engine/pokegear/pokegear.asm:1831-1845) +-- reads hJoyPressed for B and hJoyLast for UP and DOWN, and nothing else: A +-- takes nothing, and left/right have no card to page to. +function Pokegear:updateTownMap(input) + if input:wasPressed("b") then + if self.townMapClosed then return end + self.townMapClosed = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onClose then self.onClose() end return end - self.mapCursor = cursor + if input:wasPressed("up") then + self:stepMapCursor(1) + elseif input:wasPressed("down") then + self:stepMapCursor(-1) + end end -- ------------------------------------------------------------------ fly map @@ -1796,7 +1870,7 @@ function Pokegear:loadArrowSheet() -- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's -- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED. palette = (self.playerIcon and self.playerIcon.objColors) - or (gfx.palettes and gfx.palettes[1]), + or (self:pals() and self:pals()[1]), }) end end @@ -1914,7 +1988,7 @@ end -- painted white underneath its strings is what puts a cream bar behind every -- run printThrough lays down. function Pokegear:paperColor() - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 } end @@ -1929,7 +2003,7 @@ end -- space IS a font-page cell on colour 0, which is the contrast the day/time -- window and the map's KANTO label are drawn against. function Pokegear:groundColor() - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() local pal = pals and pals[1] return (pal and pal[#pal]) or { 0, 0, 0 } end @@ -1989,6 +2063,20 @@ function Pokegear:drawFlyBubble() Chrome.cursor(18, 1) end +-- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918): +-- with no card strip above it the rule turns down at (7,0) and runs back out +-- along row 2, boxing the name plate into the top right corner instead. +function Pokegear:drawTownMapRule() + self:tile(0x06, 0, 0) + for x = 1, 6 do self:tile(0x07, x, 0) end + self:tile(0x17, 7, 0) + self:tile(0x16, 7, 1) + self:tile(0x26, 7, 2) + -- `ld bc, NAME_LENGTH` from (8,2), so the run stops one short of the cap. + for x = 8, 18 do self:tile(0x07, x, 2) end + self:tile(0x17, 19, 2) +end + function Pokegear:drawMap() -- The REGION follows the player (`cp KANTO_LANDMARK` in -- PokegearMap_CheckRegion); the name box follows the CURSOR, which the @@ -2000,11 +2088,15 @@ function Pokegear:drawMap() if self.fly then self:drawFlyBubble() else - self:drawStrip() - -- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps. - self:tile(0x06, 0, 2) - for x = 1, 18 do self:tile(0x07, x, 2) end - self:tile(0x17, 19, 2) + if self.townMap then + self:drawTownMapRule() + else + self:drawStrip() + -- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps. + self:tile(0x06, 0, 2) + for x = 1, 18 do self:tile(0x07, x, 2) end + self:tile(0x17, 19, 2) + end -- PokegearMap_UpdateLandmarkName: ClearBox(8,0) 2 rows by 12 columns -- -- with ' ', which is a font-page cell and so reads as BG palette 0's @@ -2047,7 +2139,12 @@ end function Pokegear:loadPlayerIcon() if self.playerIcon ~= nil then return end self.playerIcon = false - local def = self.sprites and self.sprites.SPRITE_CHRIS + -- SPRITE_ANIM_OBJ_RED_WALK or _BLUE_WALK, which is the sheet GetPlayerIcon + -- loaded plus PAL_OW_RED or PAL_OW_BLUE + -- (../pokecrystal/engine/pokegear/pokegear.asm:2737, :2752-2757). + local def = self.sprites + and self.sprites[FieldMoves.playerSprite( + self.save and self.save.player and self.save.player.gender)] if not (def and def.image) then return end local ok, icon = pcall(SpriteRenderer.new, def, "player") if not (ok and icon) then return end @@ -2205,6 +2302,14 @@ function Pokegear:drawPlain() end return end + if self.townMap then + -- No town-map art in this cache, so the name plate the cursor walks is all + -- there is to show (../pokecrystal/engine/pokegear/pokegear.asm:700). + Chrome.box(7, 0, 13, 3) + local current = self:mapLandmark() + Chrome.print(flatName(current and current.name), 9, 1) + return + end Chrome.box(0, 0, 20, 4) local card = self:card() Chrome.print(card and card.label or "", 2, 1) @@ -2282,8 +2387,9 @@ function Pokegear:drawPanel() end -- Last: the arrow is an OBJ and composites over whatever the card drew. -- _FlyMap has no card strip and never animates it - -- (engine/pokegear/pokegear.asm:1999). - if not self.fly then self:drawModeArrow() end + -- (engine/pokegear/pokegear.asm:1999); neither does _TownMap + -- (../pokecrystal/engine/pokegear/pokegear.asm:1757). + if not (self.fly or self.townMap) then self:drawModeArrow() end G.setColor(1, 1, 1, 1) end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 9ccc3723..38f27dfb 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -64,6 +64,7 @@ local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") +local MonAnim = require("src.render.MonAnim") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") local Unown = require("src.core.gen2.Unown") @@ -314,6 +315,55 @@ function SummaryMenu:playCry() if ok and Sound and Sound.playCry then pcall(Sound.playCry, self.game.data, mon.species) end + self:startPicAnim() +end + +-- StatsScreen_PlaceFrontpic loads ANIM_MON_MENU, the longer scene. A cache +-- with no `anim` row -- every Gold and Silver one -- leaves picAnim nil. +-- ../pokecrystal/engine/pokemon/stats_screen.asm:889-901 +function SummaryMenu:startPicAnim() + self.picAnim = nil + local mon = self.mon + local def = mon and self.pokemon and self.pokemon[mon.species] + if not def then return end + local data = def.anim + if mon.species == Unown.SPECIES and def.letters then + local entry = def.letters[Unown.monLetter(mon)] + if entry and entry.anim then data = entry.anim end + end + if not data then return end + local sheet = self:picImage(data.sheet) + if not sheet then return end + local runner = MonAnim.new(data, "menu") + if not runner then return end + self.picAnim = { runner = runner, sheet = sheet, size = data.tiles * 8, + quads = {} } +end + +-- AnimateFrontpic's .loop, one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function SummaryMenu:stepPicAnim() + local state = self.picAnim + if not state then return end + state.runner:update() + if state.runner:finished() then self.picAnim = nil end +end + +-- The sheet is one column of whole pictures, base picture first. +function SummaryMenu:picAnimFrame() + local state = self.picAnim + if not state then return nil end + local frame = state.runner:currentFrame() + if frame <= 0 then return nil end + local quad = state.quads[frame] + if not quad then + local w, h = state.sheet:getDimensions() + if (frame + 1) * state.size > h then return nil end + quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size, + w, h) + state.quads[frame] = quad + end + return state.sheet, quad, state.size end function SummaryMenu:speciesDef() @@ -767,6 +817,7 @@ function SummaryMenu:updateMoveDetail(input) end function SummaryMenu:update(_dt) + self:stepPicAnim() local input = self.game and self.game.input if not input then return end if self.moveDetail then @@ -943,7 +994,7 @@ end -- PrepMonFrontpic at hlcoord 0, 0: a 7x7 block with the pic padded into it and -- the rest of the block left at the palette's colour 0. -function SummaryMenu:drawPicBlock(image, colors) +function SummaryMenu:drawPicBlock(image, colors, quad, size) if not image then return end local G = love.graphics -- A fill behind the pic reads a palette colour directly, so it has to come @@ -952,10 +1003,16 @@ function SummaryMenu:drawPicBlock(image, colors) G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) G.rectangle("fill", 0, 0, 7 * 8, 7 * 8) - local wide = math.floor(image:getWidth() / 8) + local wide = math.floor((size or image:getWidth()) / 8) local pad = PIC_PAD[wide] or PIC_PAD[7] G.setColor(1, 1, 1, 1) - local function body() G.draw(image, pad[1] * 8, pad[2] * 8) end + local function body() + if quad then + G.draw(image, quad, pad[1] * 8, pad[2] * 8) + else + G.draw(image, pad[1] * 8, pad[2] * 8) + end + end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else @@ -970,6 +1027,8 @@ function SummaryMenu:drawPic() if not image then return end local colors = self.palettes and mon.species and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil + local sheet, quad, size = self:picAnimFrame() + if sheet then return self:drawPicBlock(sheet, colors, quad, size) end self:drawPicBlock(image, colors) end diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 32b36d69..23d33dc5 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -11,6 +11,7 @@ local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") local Music = require("src.core.Music") local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") local SpriteAnims = require("src.ui.gen2.SpriteAnims") local TitleState = {} @@ -121,15 +122,75 @@ function TitleState.new(game, opts) -- How far past the 160px frame trails may fly (GB pixels); set each draw. self.trailMaxX = 200 self.musicStarted = false + + -- engine/movie/title.asm:104-127,217-273,304-338 + self.suicuneColor, self.suicuneGray = {}, {} + if type(title.suicuneFrames) == "table" then + for i, path in ipairs(title.suicuneFrames) do + self.suicuneColor[i] = tryImage(path) + end + end + if type(title.suicuneFramesGray) == "table" then + for i, path in ipairs(title.suicuneFramesGray) do + self.suicuneGray[i] = tryImage(path) + end + end + self.suicuneX = tonumber(title.suicuneX) or 48 + self.suicuneY = tonumber(title.suicuneY) or 96 + -- SuicuneFrameIterator's `and %111` clock (engine/movie/title.asm:217-243). + self.suicuneEvery = tonumber(title.suicuneEvery) or 8 + self.suicuneTick = 0 + self.suicuneFrame = 1 + self.gemColor = tryImage(title.gem) + self.gemGray = tryImage(title.gemGray) + self.gemX = tonumber(title.gemX) or 56 + self.gemRestY = tonumber(title.gemY) or 6 + self.gemStep = tonumber(title.gemStep) or 2 + -- TitleScreenEntrance (engine/menus/intro_menu.asm:1078-1123): hSCX walks + -- to 0 while alternating logo lines converge and the gem descends. + local entrance = type(title.entrance) == "table" and title.entrance or nil + self.entrance = entrance + self.entranceScx = entrance and (tonumber(entrance.scx) or 112) or 0 + self.entranceStep = entrance and (tonumber(entrance.step) or 4) or 4 + self.entranceLines = entrance and (tonumber(entrance.lines) or 80) or 0 + self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil + self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY + self.entranceSfx = title.entranceSfx + self.timeoutFrames = tonumber(title.timeoutFrames) + self.onTimeout = opts.onTimeout + -- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its + -- colour 0 backs the whole band. + local pals = type(title.palettes) == "table" and title.palettes.bg or nil + local band = type(pals) == "table" and type(pals[8]) == "table" + and pals[8][1] or nil + self.bandColor = (type(band) == "table" and #band >= 3) + and { band[1] / 255, band[2] / 255, band[3] / 255, 1 } or { 0, 0, 0, 1 } return self end -function TitleState:enter() +function TitleState:startMusic() + if self.musicStarted then return end local data = self.game and self.game.data - if data and data.audio and data.audio.runtime and not self.musicStarted then + if data and data.audio and data.audio.runtime then Music.play(data, "Music_TitleScreen", true, { reason = "title" }) self.musicStarted = true end +end + +function TitleState:enter() + if self.entrance then + -- _TitleScreen silences the channels and plays the entrance sting; + -- MUSIC_TITLE waits for the entrance to land + -- (engine/movie/title.asm:184,210-213; engine/menus/intro_menu.asm:1118-1119). + Music.stop() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime and self.entranceSfx + and data.audio.sfx and data.audio.sfx[self.entranceSfx] then + Sound.play(data, self.entranceSfx) + end + else + self:startMusic() + end -- intro.boot.title: the last card of the GS boot cinema, up with its music -- started. Gen 2 only, like the rest of intro.boot.* (see -- src/ui/gen2/CopyrightSplash.lua for why the set is new rather than shared). @@ -197,15 +258,51 @@ function TitleState:stepTrails() self.trails = alive end +-- SuicuneFrameIterator (engine/movie/title.asm:217-249): advance once per +-- `suicuneEvery` frames, cycling the four frame bases. +function TitleState:advanceSuicune() + if #self.suicuneColor == 0 then return end + local c = self.suicuneTick + self.suicuneTick = (c + 1) % 256 + if c % self.suicuneEvery ~= 0 then return end + self.suicuneFrame = math.floor(c % (self.suicuneEvery * 4) + / self.suicuneEvery) + 1 +end + function TitleState:update(_dt) self.frameCounter = self.frameCounter + 1 self:advanceHooh() + self:advanceSuicune() if self.frameCounter % self.cloudScrollEvery == 0 then self.cloudScroll = (self.cloudScroll - 1) % 160 end self:spawnTrail() self:stepTrails() + if self.entranceScx > 0 then + -- TitleScreenEntrance polls no buttons and moves the gem by 2 a frame + -- (engine/menus/intro_menu.asm:1078-1107; engine/movie/title.asm:340-362). + self.entranceScx = math.max(0, self.entranceScx - self.entranceStep) + if self.gemY < self.gemRestY then + self.gemY = math.min(self.gemRestY, self.gemY + self.gemStep) + end + if self.entranceScx == 0 then + self:startMusic() + -- TitleScreenTimer only starts once the entrance scene hands over + -- (engine/menus/intro_menu.asm:1110-1136). + self.timeoutStart = self.frameCounter + end + return + end + + -- TitleScreenTimer / TitleScreenMain's run-down back into the attract + -- loop (engine/menus/intro_menu.asm:1125-1236). + if self.timeoutFrames and self.onTimeout + and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then + self.onTimeout() + return + end + local input = self.game.input if input and (input:wasPressed("a") or input:wasPressed("start")) then if self.onContinue then self.onContinue() end @@ -242,10 +339,71 @@ function TitleState:drawCloudSpan(x0, x1) end end +-- TitleScreenEntrance's interlace: even lines slide in from the left, odd +-- from the right, converging as hSCX walks to 0 +-- (engine/menus/intro_menu.asm:1084-1103). +function TitleState:drawEntranceScreen(screen) + local G = love.graphics + local scx = self.entranceScx + if scx <= 0 then + G.draw(screen, 0, 0) + return + end + local w, h = screen:getDimensions() + local lines = math.min(self.entranceLines, h) + self.entranceQuad = self.entranceQuad or G.newQuad(0, 0, w, 1, w, h) + for line = 0, lines - 1 do + self.entranceQuad:setViewport(0, line, w, 1, w, h) + G.draw(screen, self.entranceQuad, line % 2 == 0 and -scx or scx, line) + end + -- hWY holds the copyright window off screen until the entrance lands + -- (engine/movie/title.asm:198-199; engine/menus/intro_menu.asm:1121-1122). + local bottom = (self.entranceHideBelow or h) - lines + if bottom > 0 then + self.entranceQuad:setViewport(0, lines, w, bottom, w, h) + G.draw(screen, self.entranceQuad, 0, lines) + end +end + function TitleState:drawContent() local G = love.graphics local screen, _, trail, hoohFrames = self:art() G.setColor(1, 1, 1, 1) + + local gem = self:gray() and (self.gemGray or self.gemColor) or self.gemColor + local suicuneFrames = nil + if #self.suicuneColor > 0 then + suicuneFrames = (self:gray() and #self.suicuneGray > 0) + and self.suicuneGray or self.suicuneColor + end + if gem or suicuneFrames then + -- Crystal's layering: the gem is OAM_PRIO so every BG colour 1-3 pixel + -- beats it; Suicune is BG; the window copyright covers Suicune's last + -- row (engine/movie/title.asm:81-85,334; engine/menus/intro_menu.asm:1121-1122). + local fill = self:gray() and SKY_GRAY or self.sky + G.setColor(fill[1], fill[2], fill[3], 1) + G.rectangle("fill", 0, 0, 160, 144) + G.setColor(1, 1, 1, 1) + if gem then G.draw(gem, self.gemX, self.gemY) end + if suicuneFrames then + local frame = suicuneFrames[self.suicuneFrame] or suicuneFrames[1] + if frame then G.draw(frame, self.suicuneX, self.suicuneY) end + end + if self.entranceScx <= 0 then + -- The pal-7 window line covers the BG once hWY lands at $88 + -- (engine/movie/title.asm:40-43; engine/menus/intro_menu.asm:1121-1122). + local bandTop = self.entranceHideBelow or 136 + local band = self.bandColor + G.setColor(band[1], band[2], band[3], 1) + G.rectangle("fill", 0, bandTop, 160, 144 - bandTop) + G.setColor(1, 1, 1, 1) + end + if screen then + self:drawEntranceScreen(screen) + end + return + end + if screen then -- title_screen.png already carries the cart's © GAME FREAK line on row 17. G.draw(screen, 0, 0) diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index 4b993c52..859fc424 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -39,6 +39,7 @@ local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local TileSheet = require("src.ui.gen2.TileSheet") local TrainerCard = {} @@ -68,6 +69,19 @@ local KANTO_BADGES = { "EARTH", } +-- The two slots _CGB_TrainerCard swaps by gender: CHRIS and FALKNER/KrisPalette. +-- ../pokecrystal/engine/gfx/cgb_layouts.asm:619-624, data/trainers/palettes.asm:9-12 +local PLAYER_PALETTE, BORDER_PALETTE = 1, 2 + +-- Kris's attrmap: the border takes CHRIS's palette, the portrait takes hers, +-- the top-right corner follows the border, and Clair's face borrows hers +-- (../pokecrystal/engine/gfx/cgb_layouts.asm:649-666, :698-712). +local FEMALE_ZONES = { + { 14, 1, 5, 7, BORDER_PALETTE }, + { 18, 1, 1, 1, PLAYER_PALETTE }, + { 14, 14, 4, 2, BORDER_PALETTE }, +} + -- TrainerCard_JohtoBadgesOAM lists the badges in wJohtoBadges bit order, -- which is not the order they are drawn in: Mineral comes before Storm. local BADGE_OAM_ORDER = { @@ -92,10 +106,13 @@ function TrainerCard.new(game, opts) local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).trainerCard self.gfx = gfx + self.female = Gen2Save.isFemale(self.save) and gfx ~= nil + and gfx.cardFemale ~= nil if gfx then -- One palette lookup per cell, flattened from the FillBoxCGB zones the -- way PackGfx does it. Anything outside a zone is palette 1. self.zone = {} + self.zoneDefault = BORDER_PALETTE for _, z in ipairs(gfx.paletteZones or {}) do for y = z[2], z[2] + z[4] - 1 do for x = z[1], z[1] + z[3] - 1 do @@ -103,11 +120,22 @@ function TrainerCard.new(game, opts) end end end + if self.female then + self.zoneDefault = PLAYER_PALETTE + for _, z in ipairs(FEMALE_ZONES) do + for y = z[2], z[2] + z[4] - 1 do + for x = z[1], z[1] + z[3] - 1 do + self.zone[y * SCREEN_W + x] = z[5] + end + end + end + end local function paletteFor(_, tx, ty) return self:colorsAt(tx, ty) end self.card = TileSheet.new({ - path = gfx.card, wide = gfx.cardTilesWide or 16, firstTile = 0, + path = (self.female and gfx.cardFemale) or gfx.card, + wide = gfx.cardTilesWide or 16, firstTile = 0, paletteFor = paletteFor, }) self.status = TileSheet.new({ @@ -152,7 +180,8 @@ function TrainerCard:pair(colors) end function TrainerCard:colorsAt(tx, ty) - local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 2 + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) + or self.zoneDefault or BORDER_PALETTE return self:palette(index) end diff --git a/src/ui/gen2/UnownPrinter.lua b/src/ui/gen2/UnownPrinter.lua index 473a6061..332b4ab2 100644 --- a/src/ui/gen2/UnownPrinter.lua +++ b/src/ui/gen2/UnownPrinter.lua @@ -9,7 +9,7 @@ -- is drawn and driven entirely on the cartridge, and only the A press -- (`farcall PrintUnownStamp`, engine/printer/printer.asm) walks the screen -- out the serial port to a Game Boy Printer. There is no printer here, the --- same reason PrintDiploma stays stubbed in Specials.lua, so A lands on the +-- same reason PrintDiploma's own print goes nowhere, so A lands on the -- same arm a cartridge with nothing plugged into its link port takes: the -- stamp does not print and the screen stays up. Everything else is the -- cart's. diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index d786b85c..66a3fa30 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -65,6 +65,7 @@ local PAL = { railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal) + railCrystal = { 168, 120, 236 }, -- Crystal cartridge (translucent violet) } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line @@ -322,11 +323,13 @@ function Theme.meter(x, y, w, h, pct, c) end -- The 4px version rail across the top of both windows: the only brand --- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours). +-- colour on screen (Red / Blue / Yellow / Gold / Silver / Crystal cartridge +-- colours). function Theme.versionRail(x, y, w, h) if not G then return end local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver, + PAL.railCrystal, } local seg = w / #bars for i, c in ipairs(bars) do diff --git a/src/world/gen2/FieldMoves.lua b/src/world/gen2/FieldMoves.lua index a9db2afc..66f8d57c 100644 --- a/src/world/gen2/FieldMoves.lua +++ b/src/world/gen2/FieldMoves.lua @@ -36,6 +36,7 @@ -- `action` names the World method that carries it out; everything else in the -- table is that action's argument. +local GameVersion = require("src.core.GameVersion") local Permissions = require("src.world.gen2.Permissions") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") @@ -152,14 +153,41 @@ FieldMoves.KANTO_BADGES = { -- World:setEngineFlag / World:engineFlag route badge ids here so there is one -- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to -- save.bugContest rather than kept as a second copy. -FieldMoves.BADGE_FLAG = {} -for index, name in ipairs(FieldMoves.JOHTO_BADGES) do - FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name } -end -for index, name in ipairs(FieldMoves.KANTO_BADGES) do - FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name } +-- Crystal declares 162 engine flags to Gold's 93 and the badge block sits one +-- higher (constants/engine_flags.asm:39 vs pokegold's :38), so the ids come +-- from the cache's engineFlagOrder when it has one. +-- Crystal only; pokegold constants/engine_flags.asm:4-111 has no such row. +FieldMoves.FEMALE_FLAG_NAME = "ENGINE_PLAYER_IS_FEMALE" + +function FieldMoves.bindEngineFlags(order) + local byName = {} + if type(order) == "table" then + -- pairs, not ipairs: a const_skip leaves a hole and ipairs would stop + -- there, silently dropping every badge past it. + for index, name in pairs(order) do + if type(index) == "number" and type(name) == "string" then + byName[name] = index - 1 + end + end + end + local flags = {} + local function place(names, store, goldBase) + for index, name in ipairs(names) do + local id = byName["ENGINE_" .. name .. "BADGE"] or (goldBase + index) + flags[id] = { store = store, name = name } + end + end + place(FieldMoves.JOHTO_BADGES, "badges", 25) + place(FieldMoves.KANTO_BADGES, "kantoBadges", 33) + FieldMoves.BADGE_FLAG = flags + -- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte + -- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121). + FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME] + return flags end +FieldMoves.bindEngineFlags(nil) + function FieldMoves.hasBadge(save, badge) if not badge then return true end local owned = save and save.player and save.player.badges @@ -357,6 +385,39 @@ FieldMoves.STATE_SPRITE = { surf_pika = "SPRITE_SURFING_PIKACHU", } +-- data/sprites/player_sprites.asm:8-13 KrisStateSprites, the other half of the +-- table GetPlayerSprite picks between (engine/overworld/overworld.asm:55-64). +FieldMoves.STATE_SPRITE_FEMALE = { + normal = "SPRITE_KRIS", + bike = "SPRITE_KRIS_BIKE", + surf = "SPRITE_SURF", + surf_pika = "SPRITE_SURFING_PIKACHU", +} + +-- wPlayerGender's PLAYERGENDER_FEMALE_F, as the save spells it +-- (constants/ram_constants.asm:176-177). +function FieldMoves.isFemale(gender) + return gender == "female" +end + +-- GetPlayerSprite's table pick and row walk +-- (engine/overworld/overworld.asm:57-64, :67-75). +function FieldMoves.stateSprite(state, gender) + local table_ = FieldMoves.isFemale(gender) + and FieldMoves.STATE_SPRITE_FEMALE or FieldMoves.STATE_SPRITE + return table_[state] or table_[FieldMoves.PLAYER_NORMAL] +end + +function FieldMoves.playerSprite(gender) + return FieldMoves.stateSprite(FieldMoves.PLAYER_NORMAL, gender) +end + +-- Whether the cache carries Kris at all; Gold and Silver have no +-- KrisStateSprites to extract (pokegold data/sprites/player_sprites.asm:1-6). +function FieldMoves.hasGenderChoice(sprites) + return (sprites and sprites[FieldMoves.STATE_SPRITE_FEMALE.normal]) ~= nil +end + function FieldMoves.isBiking(state) return state == FieldMoves.PLAYER_BIKE end @@ -536,10 +597,14 @@ end -- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET -- -- so FLASH is refused in a lit cave and on a route alike, and the refusal -- is FieldMoveFailed's generic "Can't use that here." +-- +-- ../pokecrystal/engine/events/overworld.asm:284-287 puts SpecialAerodactylChamber +-- between the two, and its carry is a second way into `.useflash`. function FieldMoves.flashFromMenu(ctx) local refused = badgeGate(ctx, "FLASH") if refused then return refused end - if not ctx.dark then + local chamber = ctx.openAerodactylWall and ctx.openAerodactylWall() + if not ctx.dark and not chamber then return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } end return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH } @@ -562,6 +627,11 @@ function FieldMoves.surfFromMenu(ctx) or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then return { ok = false, text = FieldMoves.TEXT.CANT_SURF } end + -- Crystal's added `farcall CheckFacingObject`, which pokegold's :339 tags + -- BUG (../pokecrystal/engine/events/overworld.asm:364-365). + if ctx.facingObject and GameVersion.fixes().surfOntoNpc then + return { ok = false, text = FieldMoves.TEXT.CANT_SURF } + end return { ok = true, action = "surf", state = FieldMoves.surfType(ctx.mon), diff --git a/src/world/gen2/UnownWords.lua b/src/world/gen2/UnownWords.lua new file mode 100644 index 00000000..852679eb --- /dev/null +++ b/src/world/gen2/UnownWords.lua @@ -0,0 +1,225 @@ +-- ../pokecrystal/engine/events/unown_walls.asm:102 DisplayUnownWords, and the +-- Unown alphabet it writes out of ../pokecrystal/constants/charmap.asm:424; +-- :1 HoOhChamber, :13 OmanyteChamber, :54 SpecialAerodactylChamber and :81 +-- SpecialKabutoChamber, the four routines that open the chambers' walls. + +local Assets = require("src.render.Assets") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") + +local UnownWords = {} +UnownWords.__index = UnownWords +UnownWords.isOpaque = false + +-- engine/tilesets/map_palettes.asm:40, ../pokecrystal/constants/tileset_constants.asm:55 +UnownWords.BANK1 = 0x80 +UnownWords.BROWN = 6 + +-- ../pokecrystal/engine/events/unown_walls.asm:225 .YChar, :237 .ZChar, :249 .DashChar +local FIXED = { + [0x60] = { 0x5b, 0x5c, 0x4d, 0x5d }, + [0x62] = { 0x4e, 0x4f, 0x5e, 0x5f }, + [0x64] = { 0x02, 0x03, 0x03, 0x02 }, +} + +function UnownWords.square(char) + local fixed = FIXED[char] + if fixed then return fixed[1], fixed[2], fixed[3], fixed[4] end + local base = UnownWords.BANK1 + char + return base, base + 1, base + 0x10, base + 0x11 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:122-127 +function UnownWords.origin(wall) + return (wall.x1 or 0) + 1, (wall.y1 or 0) + 2 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:119 MenuBox, home/menu.asm:131 GetMenuBoxDims +function UnownWords.boxRect(wall) + local x1, y1 = wall.x1 or 0, wall.y1 or 0 + return x1, y1, (wall.x2 or x1) - x1 + 1, (wall.y2 or y1) - y1 + 1 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:182 _DisplayUnownWords_CopyWord +function UnownWords.layout(wall) + local tx, ty = UnownWords.origin(wall) + local out = {} + for index, char in ipairs(wall.chars or {}) do + local tl, tr, bl, br = UnownWords.square(char) + out[index] = { + tx = tx + (index - 1) * 2, ty = ty, + tl = tl, tr = tr, bl = bl, br = br, + } + end + return out +end + +-- ../pokecrystal/constants/event_flags.asm:486-489, the four Crystal-only +-- EVENT_WALL_OPENED_IN_*_CHAMBER bits. +UnownWords.WALL_OPENED = { + HO_OH = 806, + KABUTO = 807, + OMANYTE = 808, + AERODACTYL = 809, +} + +-- ../pokecrystal/engine/events/unown_walls.asm:60, :87 -- the +-- GetMapAttributesPointer compare the two non-special routines are gated on. +UnownWords.CHAMBER_MAPS = { + HO_OH = "RUINS_OF_ALPH_HO_OH_CHAMBER", + KABUTO = "RUINS_OF_ALPH_KABUTO_CHAMBER", + OMANYTE = "RUINS_OF_ALPH_OMANYTE_CHAMBER", + AERODACTYL = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", +} + +-- ../pokecrystal/engine/events/unown_walls.asm:4, :22 +UnownWords.HO_OH = "HO_OH" +UnownWords.WATER_STONE = "WATER_STONE" + +-- ../pokecrystal/engine/events/unown_walls.asm:16 EventFlagAction CHECK_FLAG +function UnownWords.wallOpened(events, chamber) + local flag = UnownWords.WALL_OPENED[chamber] + if not (events and flag) then return false end + return events:get(flag) and true or false +end + +-- ../pokecrystal/engine/events/unown_walls.asm:8 EventFlagAction SET_FLAG +function UnownWords.openWall(events, chamber) + local flag = UnownWords.WALL_OPENED[chamber] + if not (events and flag) then return false end + if events:get(flag) then return false end + events:set(flag, true) + return true +end + +-- ../pokecrystal/engine/events/unown_walls.asm:2-5: wPartySpecies[0], which +-- holds EGG rather than the hatchling's species while a slot is an egg. +function UnownWords.leadIsHoOh(party) + local lead = party and party[1] + if not lead or lead.isEgg then return false end + return lead.species == UnownWords.HO_OH +end + +-- ../pokecrystal/engine/events/unown_walls.asm:28-43: wPartyCount down to 1, +-- MON_ITEM on each, so the LAST slot holding one is the one it stops at. +function UnownWords.waterStoneSlot(party) + party = party or {} + for slot = #party, 1, -1 do + local mon = party[slot] + if mon and mon.item == UnownWords.WATER_STONE then return slot end + end + return nil +end + +-- ../pokecrystal/engine/events/unown_walls.asm:54, whose carry FlashFunction +-- jumps on at ../pokecrystal/engine/events/overworld.asm:285. +function UnownWords.aerodactylChamber(events, mapId) + if mapId ~= UnownWords.CHAMBER_MAPS.AERODACTYL then return false end + UnownWords.openWall(events, "AERODACTYL") + return true +end + +-- ../pokecrystal/engine/events/unown_walls.asm:81, off the escape rope arm of +-- EscapeRopeOrDig (../pokecrystal/engine/events/overworld.asm:809). +function UnownWords.kabutoChamber(events, mapId) + if mapId ~= UnownWords.CHAMBER_MAPS.KABUTO then return false end + UnownWords.openWall(events, "KABUTO") + return true +end + +-- ../pokecrystal/constants/script_constants.asm:319 UNOWNWORDS_* +function UnownWords.wallFor(data, scriptVar) + local walls = data and data.gen2EventTables and data.gen2EventTables.unownWalls + if not walls then return nil end + return walls[(scriptVar or 0) + 1] +end + +-- opts: wall (an events.unownWalls row), world, onClose() +function UnownWords.new(game, opts) + opts = opts or {} + local self = setmetatable({}, UnownWords) + self.game = game + self.wall = opts.wall + self.world = opts.world + self.onClose = opts.onClose + self.done = false + self.squares = self.wall and UnownWords.layout(self.wall) or {} + return self +end + +-- ../pokecrystal/engine/events/unown_walls.asm:155 _DisplayUnownWords_FillAttr +function UnownWords:tileset() + local world = self.world + local map = world and world.map + local def = map and map.def + local tileset = def and world.tilesets and world.tilesets[def.tileset] + if not tileset or not tileset.image then return nil end + if self.atlas == nil then + local ok, image = pcall(Assets.image, tileset.image) + self.atlas = ok and image or false + if self.atlas then self.atlas:setFilter("nearest", "nearest") end + local set = world.palettes + and Palettes.bgSet(world.palettes, def, world.daytime or "DAY") + self.colors = set and set[UnownWords.BROWN] or nil + end + if not self.atlas then return nil end + return tileset, self.atlas +end + +-- ../pokecrystal/engine/events/unown_walls.asm:147 PlayClickSFX, :148 CloseWindow +function UnownWords:finish() + if self.done then return end + self.done = true + local game = self.game + if game and game.data then Sound.play(game.data, "SFX_READ_TEXT_2") end + if game and game.stack then game.stack:pop() end + if self.onClose then self.onClose() end +end + +function UnownWords:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("b") then self:finish() end +end + +function UnownWords:draw() + local wall = self.wall + if not wall then return end + local bx, by, bw, bh = UnownWords.boxRect(wall) + Font.drawBox(bx, by, bw, bh) + local tileset, atlas = self:tileset() + if not tileset then return end + local perRow = tileset.tilesPerRow or 16 + local aw, ah = atlas:getDimensions() + local quads = {} + local function quadFor(tile) + local q = quads[tile] + if not q then + q = love.graphics.newQuad((tile % perRow) * 8, + math.floor(tile / perRow) * 8, 8, 8, aw, ah) + quads[tile] = q + end + return q + end + local function body() + love.graphics.setColor(1, 1, 1, 1) + for _, square in ipairs(self.squares) do + local x, y = square.tx * 8, square.ty * 8 + love.graphics.draw(atlas, quadFor(square.tl), x, y) + love.graphics.draw(atlas, quadFor(square.tr), x + 8, y) + love.graphics.draw(atlas, quadFor(square.bl), x, y + 8) + love.graphics.draw(atlas, quadFor(square.br), x + 8, y + 8) + end + end + if self.colors and GbcPalette.available() then + GbcPalette.with(self.colors, body) + else + body() + end + love.graphics.setColor(0, 0, 0, 1) +end + +return UnownWords diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ef84997a..df18d937 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -41,6 +41,7 @@ local HiddenItems = require("src.world.gen2.HiddenItems") local Mail = require("src.core.gen2.Mail") local Map = require("src.world.gen2.Map") local Palettes = require("src.world.gen2.Palettes") +local UnownWords = require("src.world.gen2.UnownWords") local Mon = require("src.battle.gen2.Mon") local Movement = require("src.script.gen2.Movement") local Music = require("src.core.Music") @@ -116,6 +117,14 @@ local VAR = { XCOORD = 0x12, YCOORD = 0x13, SPECIALPHONECALL = 0x14, + -- ../pokecrystal/constants/script_constants.asm:69-74, the six rows Gold's + -- table stops short of; ../pokecrystal/engine/overworld/variables.asm:62-67. + BT_WIN_STREAK = 0x15, + KURT_APRICORNS = 0x16, + CALLERID = 0x17, + BLUECARDBALANCE = 0x18, + BUENASPASSWORD = 0x19, + KENJI_BREAK = 0x1a, } -- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row: @@ -374,6 +383,13 @@ local function itemByIndex(items, index) return nil end +-- One WRAM byte, the width every VAR_* store is (`ld a, [de]` / `ld [de], a`). +local function byteOf(value) + local n = math.floor(tonumber(value) or 0) + if n < 0 then n = 0 end + return n % 256 +end + -- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN -- and VAR.BADGES are all "how many of these are set" reads off one. local function countFlags(flags) @@ -810,6 +826,7 @@ function World:load() self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {} self.constants = self:dataTable("gen2Constants", "data/generated/constants.lua") or {} + FieldMoves.bindEngineFlags(self.constants.engineFlagOrder) -- The side tables a script command NAMES rather than carries: the phone -- book, the in-game trades, the elevator's floor labels and the decoration -- descriptions. A cache built before the extractor reached them has no @@ -1256,7 +1273,7 @@ function World:load() end, -- ---- encounters -------------------------------------------------------- - setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end, + setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end, rollWild = function() return self:rollWild() end, -- The WRAM bytes the ENGINE owns rather than the script: nil means "not -- mine", and the VM falls back to its own sparse store. @@ -1573,9 +1590,78 @@ function World:readVar(varId) if varId == VAR.SPECIALPHONECALL then return self:specialCall() end + if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then + return self:crystalVar(varId) + end return 0 end +-- ../pokecrystal/engine/overworld/variables.asm:62-67, the six .VarActionTable +-- rows Crystal appends past VAR_SPECIALPHONECALL. +function World:crystalVar(varId) + -- ../pokecrystal/ram/wram.asm:3286 wCurCaller, which this port parks on the + -- VM (src/script/gen2/CallAsm.lua:190). + if varId == VAR.CALLERID then + return (self.vm and self.vm.curPhoneCaller) or 0 + end + local save = self.game and self.game.save + if not save then return 0 end + -- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers. + if varId == VAR.BT_WIN_STREAK then + return byteOf(Gen2Save.battleTowerState(save).streak) + end + -- ../pokecrystal/engine/events/kurt.asm:24,45 wKurtApricornQuantity. + if varId == VAR.KURT_APRICORNS then + return byteOf(save.kurtApricornQuantity) + end + local crystal = Gen2Save.crystalState(save) + if varId == VAR.BLUECARDBALANCE then + return byteOf(crystal.buenaPassword.balance) + end + if varId == VAR.BUENASPASSWORD then + return byteOf(crystal.buenaPassword.word) + end + -- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown. + if varId == VAR.KENJI_BREAK then + return byteOf(crystal.kenjiBreak) + end + return 0 +end + +-- The three of them Script_writevar can reach: RETVAR_ADDR_DE rows write the +-- variable itself, RETVAR_STRBUF2 rows write the scratch buffer and are lost +-- (../pokecrystal/engine/overworld/variables.asm:21-25). +function World:setCrystalVar(varId, value) + value = byteOf(value) + if varId == VAR.CALLERID then + if self.vm then self.vm.curPhoneCaller = value end + return + end + local save = self.game and self.game.save + if not save then return end + local buena = Gen2Save.crystalState(save).buenaPassword + if varId == VAR.BLUECARDBALANCE then + buena.balance = value + elseif varId == VAR.BUENASPASSWORD then + buena.word = value + end +end + +-- ../pokecrystal/engine/events/kurt.asm:19-45 SelectApricornForKurt, whose +-- byte is what `verbosegiveitemvar , VAR_KURT_APRICORNS` hands over. +function World:setKurtApricornQuantity(count) + local save = self.game and self.game.save + if not save then return end + save.kurtApricornQuantity = byteOf(count) +end + +-- ../pokecrystal/engine/overworld/time.asm:136-142, the 3..6 day roll. +function World:setKenjiBreak(days) + local save = self.game and self.game.save + if not save then return end + Gen2Save.crystalState(save).kenjiBreak = byteOf(days) +end + -- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm -- GS_VERSION). function World:gsVersion() @@ -1622,6 +1708,11 @@ function World:engineFlag(flag) local owned = player and player[badge.store] return type(owned) == "table" and owned[badge.name] == true end + -- Same one-store rule for ENGINE_PLAYER_IS_FEMALE, which IS wPlayerGender + -- (../pokecrystal/data/events/engine_flags.asm:131); Gold's FEMALE_FLAG is nil. + if flag == FieldMoves.FEMALE_FLAG then + return Gen2Save.isFemale(save) + end -- Same one-store rule for the day care. data/events/engine_flags.asm:18-20 -- maps the three ids onto DAYCAREMAN_HAS_EGG_F / DAYCAREMAN_HAS_MON_F / -- DAYCARELADY_HAS_MON_F, i.e. they ARE the bits DayCare_InitBreeding, @@ -1661,6 +1752,13 @@ function World:setEngineFlag(flag, value) save.player[badge.store][badge.name] = value and true or nil return end + -- InitGender is the only writer on the cart, so this exists only to keep a + -- stray setflag out of save.engineFlags (../pokecrystal/engine/menus/init_gender.asm:23-42). + if flag == FieldMoves.FEMALE_FLAG and save then + save.player = save.player or {} + save.player.gender = value and "female" or "male" + return + end -- The write half of the day-care aliases. DayCareManScript_Outside's -- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart -- script that writes any of the three, and it is idempotent because @@ -1697,6 +1795,9 @@ function World:writeVar(varId, value) local state = PLAYER_STATE_BY_ID[value or 0] if state then self:applyPlayerState(state) end end + if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then + self:setCrystalVar(varId, value) + end end function World:battleType() @@ -1854,6 +1955,16 @@ end -- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) hands -- the same slot to the Route 37 twins. So the pooled objects that read -- through the slot have to go with it. +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1564-1575 writes +-- the sprite byte into wMapObjects, the LIVE copy, so the map def is untouched. +function World:setObjectSprite(objectId, spriteName) + local npc = self:objectEntity(objectId) + local spriteDef = spriteName and self.sprites and self.sprites[spriteName] + if not (npc and spriteDef) then return false end + if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end + return true +end + function World:setVariableSprite(slot, spriteIndex) if slot == nil then return end self.variableSprites[slot] = spriteIndex @@ -2318,12 +2429,10 @@ end -- the map pair and DAILYFLAGS1_SWARM are set by the one command. A port that -- stored only the map would leave the Dunsparce call live forever, because -- CheckSwarmFlag answers off the flag and clears the pair itself. -function World:setSwarm(group, mapNum) +function World:setSwarm(group, mapNum, kind) local save = self.game and self.game.save if not save then return end - save.dailyFlags = save.dailyFlags or {} - save.dailyFlags.swarm = true - save.swarmMap = self:mapIdByGroupMap(group, mapNum) + Roamers.Swarm.set(save, self:mapIdByGroupMap(group, mapNum), kind) end -- Script_loadwildmon's other half: roll the CURRENT map's own table the way a @@ -3472,7 +3581,7 @@ function World:specialHooks() takeItem = function(index, qty) return self:takeItem(index, qty) end, engineFlag = function(flag) return self:engineFlag(flag) end, setEngineFlag = function(flag, v) self:setEngineFlag(flag, v) end, - setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end, + setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end, dayCare = function(side, onDone) self:dayCare(side, onDone) end, givePokeMail = function(mail) return self:givePokeMail(mail) end, checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end, @@ -3504,6 +3613,14 @@ function World:specialHooks() magnetTrain = function(toGoldenrod, onDone) self:magnetTrain(toGoldenrod, onDone) end, + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:220-223 + startTowerBattle = function(trainer, onDone) + return self:startBattle({ trainer = trainer, battleTower = true }, onDone) + end, + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1552-1575 + setObjectSprite = function(objectId, spriteName) + return self:setObjectSprite(objectId, spriteName) + end, pushScreen = function(id, opts) return self:pushScreen(id, opts) end, monName = function(index) local id, def = speciesByIndex( @@ -3537,9 +3654,36 @@ function World:specialHooks() self:openScriptMenu(header, "vertical", onChoose) end, rareWildMon = function() return self:rareWildMon() end, + -- ../pokecrystal/engine/menus/save.asm:181 AskOverwriteSaveFile and :266 + -- _SaveGameData, the two halves of Link_SaveGame (:63). + saveFileState = function() return self:saveFileState() end, + writeSave = function() return self:writeSave() end, + setKurtApricornQuantity = function(n) self:setKurtApricornQuantity(n) end, + setKenjiBreak = function(days) self:setKenjiBreak(days) end, } end +-- AskOverwriteSaveFile's two reads: wSaveFileExists, and +-- CompareLoadedAndSavedPlayerID (../pokecrystal/engine/menus/save.asm:224), +-- which is what picks AlreadyASaveFileText over AnotherSaveFileText. +function World:saveFileState() + local save = self.game and self.game.save + local version = save and save.version + if not Gen2Save.exists(version) then return false, false end + local stored = Gen2Save.load(version) + local mine = save and save.player and save.player.id + local theirs = stored and stored.player and stored.player.id + return true, (mine ~= nil and mine == theirs) +end + +-- _SaveGameData, through the writer the SAVE menu is handed +-- (src/core/Game2.lua:435) so the save.write veto holds here too. +function World:writeSave() + local game = self.game + if not (game and game.writeSave) then return false end + return game:writeSave() ~= false +end + -- RandomUnseenWildMon's lookup half. The routine picks one of the THREE -- RAREST grass slots on the map (`and %11 / jr z` rerolls 0, so it is slots 5, -- 6 or 7 of the seven) and drops it if that species is also one of the FOUR @@ -4441,6 +4585,9 @@ function World:useEscapeRope(itemId) local items = self.game and self.game.data and self.game.data.items local def = items and items[itemId or "ESCAPE_ROPE"] self:takeItem(def and def.index, 1) + -- ../pokecrystal/engine/events/overworld.asm:809, between .escaperope and + -- QueueScript. + UnownWords.kabutoChamber(self.events, self.map and self.map.id) self.queuedFieldMove = { ok = true, action = "escaperope", destMap = destMapId, destWarp = destWarp, @@ -5186,6 +5333,9 @@ function World:fieldContext(mon) facingX = fx, facingY = fy, facingColl = map:cellCollision(fx, fy), playerColl = map:cellCollision(p.cellX, p.cellY), + -- Crystal's SurfFunction.TrySurf is the only field move that asks + -- (../pokecrystal/engine/events/overworld.asm:364). + facingObject = self:facingObject(), upColl = map:cellCollision(p.cellX, p.cellY - 1), tileset = map.def and map.def.tileset, facingBlock = blockId, @@ -5199,6 +5349,11 @@ function World:fieldContext(mon) -- FlashFunction tests wTimeOfDayPalset, not the map header, so a -- PALETTE_DARK map that FLASH has already lit refuses a second FLASH. dark = Palettes.isDarkness(map.def, self:hour(), self.flashUsed), + -- ../pokecrystal/engine/events/overworld.asm:285, called by FLASH only and + -- only after the badge gate, because it SETS the wall-opened flag. + openAerodactylWall = function() + return UnownWords.aerodactylChamber(self.events, map.id) + end, } end @@ -5328,12 +5483,26 @@ function World:refreshMapImages() return true end +-- wPlayerGender, the byte GetPlayerSprite and AddMapObject both branch on +-- (engine/overworld/overworld.asm:61-64, engine/overworld/player_object.asm:32-39). +function World:playerGender() + local save = self.game and self.game.save + return save and save.player and save.player.gender or nil +end + +-- The Chris/Kris sheet the player wears with no state on it +-- (data/sprites/player_sprites.asm:2, :9). +function World:playerSpriteName() + return FieldMoves.playerSprite(self:playerGender()) or PLAYER_SPRITE +end + -- UpdatePlayerSprite (data/sprites/player_sprites.asm ChrisStateSprites): the -- player's sprite is a pure function of wPlayerState, which is what makes -- getting on and off a Lapras a one-byte change rather than an animation. function World:applyPlayerState(state) self.playerState = state or FieldMoves.PLAYER_NORMAL - local name = FieldMoves.STATE_SPRITE[self.playerState] or PLAYER_SPRITE + local name = FieldMoves.stateSprite(self.playerState, self:playerGender()) + or PLAYER_SPRITE local def = self.sprites and self.sprites[name] if def and self.player then self.player:setSprite(def) @@ -5884,6 +6053,9 @@ function World:startBattle(opts, onDone) -- wBattleType, when the script armed one: the FORCESHINY / TRAP -- no-escape rules live in Battle:tryRun and the force-switch handler. battleType = opts.battleType, + -- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/ + -- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off. + battleTower = opts.battleTower, }) self:playBattleMusic(opts) local function pushBattle() @@ -7397,26 +7569,43 @@ function World:interact() return self:interactBody() end +-- CheckFacingObject (engine/overworld/npc_movement.asm:229-248): "Double the +-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand +-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter +-- itself and the object is one further on. Without this the press finds an +-- empty wall and nothing happens -- which is to say no nurse and no clerk in +-- the game could be talked to at all. +-- +-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events +-- and the tile-collision events still read the tile actually faced. +function World:facingObjectCell() + local p = self.player + if not p then return nil end + local d = Map.DELTA[p.facing] or Map.DELTA.down + local fx, fy = p.cellX + d[1], p.cellY + d[2] + if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then + return p.cellX + d[1] * 2, p.cellY + d[2] * 2 + end + return fx, fy +end + +-- The carry CheckFacingObject answers with: IsNPCAtCoord, and then only when +-- that object's OBJECT_WALKING reads STANDING (npc_movement.asm:250-266). +function World:facingObject() + local ox, oy = self:facingObjectCell() + if not ox then return nil end + local npc = self:npcAt(ox, oy) + if npc and npc.moving then return nil end + return npc +end + function World:interactBody() if self:busy() or not self.player or not self.vm then return false end local p = self.player if p.moving then return false end local d = Map.DELTA[p.facing] local fx, fy = p.cellX + d[1], p.cellY + d[2] - -- CheckFacingObject (engine/overworld/npc_movement.asm:229): "Double the - -- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand - -- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter - -- itself and the object is one further on. Without this the press finds an - -- empty wall and nothing happens -- which is to say no nurse and no clerk in - -- the game could be talked to at all. - -- - -- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events - -- and the tile-collision events below still read the tile actually faced. - local ox, oy = fx, fy - if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then - ox, oy = p.cellX + d[1] * 2, p.cellY + d[2] * 2 - end - local npc = self:npcAt(ox, oy) + local npc = self:npcAt(self:facingObjectCell()) -- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts -- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any -- setlasttalked of their own. The port only wrote it from the explicit @@ -8510,13 +8699,13 @@ function World:setMap(mapId, cx, cy, facing, opts) -- GetWarpDestCoords / EnterMapConnection / EnterMapSpawnPoint write wXCoord -- and wYCoord BEFORE HandleNewMap (data/maps/setup_scripts.asm:79-106). local face = facing or (self.player and self.player.facing) or "down" - local chris = self.sprites and self.sprites[PLAYER_SPRITE] + local playerDef = self.sprites and self.sprites[self:playerSpriteName()] if self.player then self.player.cellX, self.player.cellY = cx, cy self.player.px, self.player.py = cx * 16, cy * 16 self.player.facing = face - if chris and not self.player.sprite then - self.player:setSprite(chris) + if playerDef and not self.player.sprite then + self.player:setSprite(playerDef) end if not opts.seamless then self.player.moving = false @@ -8524,7 +8713,7 @@ function World:setMap(mapId, cx, cy, facing, opts) self.player.targetX, self.player.targetY = nil, nil end else - self.player = Player.new(cx, cy, face, chris) + self.player = Player.new(cx, cy, face, playerDef) end -- LoadMapObjects rebuilds OBJECT_FLAGS2 from scratch, so IN_GRASS is decided -- by the cell the player arrives on (engine/overworld/map_objects.asm:247). diff --git a/tests/crystal_import_test.lua b/tests/crystal_import_test.lua new file mode 100644 index 00000000..915d732b --- /dev/null +++ b/tests/crystal_import_test.lua @@ -0,0 +1,165 @@ +-- Crystal manifest shape and the specials surface the extractor pins to it. +-- ROM-free: reads tools/rom_manifest_crystal.json out of the source tree. +-- luajit tests/crystal_import_test.lua +-- Also dofile'd by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("crystal import") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Json = require("src.link.Json") +local GameVersion = require("src.core.GameVersion") + +local function manifest(path) + local file = assert(io.open(path, "r")) + local data = assert(Json.decode(file:read("*a"))) + file:close() + return data +end + +local function size(tbl) + local n = 0 + for _ in pairs(tbl or {}) do n = n + 1 end + return n +end + +local crystal = manifest("tools/rom_manifest_crystal.json") +local gold = manifest("tools/rom_manifest_gold.json") + +-- ------- 1. the manifest the launcher row points at + +eq(GameVersion.VERSIONS.crystal.manifest, "tools/rom_manifest_crystal.json", + "the crystal row names this manifest") +eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1, + "and the manifest carries the same sha1") +eq(crystal.generation, 2, "generation 2") +eq(crystal.format, gold.format, "same manifest format as Gold") + +-- ------- 2. content counts + +eq(size(crystal.maps), 388, "388 maps") +eq(size(crystal.tilesets), 36, "36 tilesets") +eq(size(crystal.pokemonAssets), 251, "251 pokemon asset rows") +eq(size(crystal.constants), 50, "50 constants keys") +eq(type(crystal.constants.engineFlagOrder), "table", + "the 50th is engineFlagOrder, the key Gold has no counterpart for") +check(size(crystal.symbols) > 2000, + ("symbols table is populated (%d)"):format(size(crystal.symbols))) +check(size(crystal.charmap) > 200, + ("charmap is populated (%d)"):format(size(crystal.charmap))) +check(#(crystal.fontCharmap or {}) > 200, + ("fontCharmap is populated (%d)"):format(#(crystal.fontCharmap or {}))) + +-- pokecrystal/constants/map_constants.asm:504, pokegold:484 +eq(size(gold.maps), 368, "Gold names 368") +local added, removed = 0, 0 +for name in pairs(crystal.maps) do + if not gold.maps[name] then added = added + 1 end +end +for name in pairs(gold.maps) do + if not crystal.maps[name] then removed = removed + 1 end +end +eq(added, 21, "Crystal adds 21 maps") +eq(removed, 1, "and drops one") +-- pokegold/constants/map_constants.asm:152 +eq(crystal.maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil, + "the one Gold map Crystal drops is ECRUTEAK_TIN_TOWER_BACK_ENTRANCE") +check(crystal.maps.BATTLE_TOWER_1F ~= nil, "and BATTLE_TOWER_1F is new") + +-- ------- 3. every map row is addressable + +local badMap +for name, row in pairs(crystal.maps) do + if type(row) ~= "table" then badMap = name; break end +end +eq(badMap, nil, "every map row is a table") + +-- ------- 4. the text labels resolve, as they must for the Dialogue stage + +local labels = (crystal.text or {}).labels or {} +check(#labels > 800, ("crystal names %d text labels"):format(#labels)) +local unresolved = {} +for _, label in ipairs(labels) do + if not crystal.symbols[label] then unresolved[#unresolved + 1] = label end +end +eq(#unresolved, 0, + ("every crystal text label resolves to a symbol (%s)") + :format(table.concat(unresolved, ", "):sub(1, 60))) + +-- ------- 5. specials: constants.specialOrder against the handler table + +local Specials = require("src.script.gen2.Specials") + +local order = crystal.constants.specialOrder +check(type(order) == "table", "constants.specialOrder is a list") +eq(#order, 169, "Crystal's SpecialsPointers has 169 rows") + +local missing = {} +for index, name in ipairs(order) do + if not Specials.ALL[name] then + missing[#missing + 1] = ("%d (%s)"):format(index - 1, name) + end +end +eq(#missing, 0, + ("every crystal special has a handler (%s)") + :format(table.concat(missing, ", "):sub(1, 80))) + +local goldOrder = gold.constants.specialOrder +local goldMissing = {} +for index, name in ipairs(goldOrder or {}) do + if not Specials.ALL[name] then + goldMissing[#goldMissing + 1] = ("%d (%s)"):format(index - 1, name) + end +end +eq(#goldMissing, 0, + ("every gold special has a handler too (%s)") + :format(table.concat(goldMissing, ", "):sub(1, 80))) + +local sameOrder = #order == #(goldOrder or {}) +if sameOrder then + for index, name in ipairs(order) do + if goldOrder[index] ~= name then sameOrder = false; break end + end +end +check(not sameOrder, "the Crystal and Gold special orders are not the same") + +-- ------- 6. handler bookkeeping + +local overlap = {} +for name in pairs(Specials.STUBS) do + if Specials.HANDLERS[name] then overlap[#overlap + 1] = name end +end +eq(#overlap, 0, + ("HANDLERS and STUBS are disjoint (%s)"):format(table.concat(overlap, ", "))) + +local unexplained = {} +for name in pairs(Specials.STUBS) do + local reason = (Specials.STUB_REASONS or {})[name] + if type(reason) ~= "string" or reason == "" then + unexplained[#unexplained + 1] = name + end +end +eq(#unexplained, 0, + ("every stub records a reason (%s)") + :format(table.concat(unexplained, ", "):sub(1, 80))) + +-- ------- 7. the Crystal-only assets the completeness gate pins + +local importerSource = assert(io.open("src/import/RomImporter.lua", "r")) +local importerText = importerSource:read("*a") +importerSource:close() +for _, path in ipairs({ + "assets/generated/title/crystal_logo.png", + "assets/generated/title/crystal_wordmark.png", + "assets/generated/title/crystal_suicune.png", + "assets/generated/splash/ditto.png", + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", +}) do + check(importerText:find(path, 1, true) ~= nil, + "RomImporter still requires " .. path) +end + +S.finish() diff --git a/tests/crystal_world_test.lua b/tests/crystal_world_test.lua new file mode 100644 index 00000000..d86d2226 --- /dev/null +++ b/tests/crystal_world_test.lua @@ -0,0 +1,158 @@ +-- Crystal world data: the roamer roster and swarm pairs that differ from +-- Gold, then the facts only a real Crystal cache can answer. +-- Self-contained: `luajit tests/crystal_world_test.lua`; also dofile'd by +-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("crystal world") +local check, eq = S.check, S.eq + +local Permissions = require("src.world.gen2.Permissions") +local Roamers = require("src.core.gen2.Roamers") +local Swarm = Roamers.Swarm + +-- ---------------------------------------------------------------- ROM-free + +check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable") +check(not Permissions.isWalkable(0x07), "COLL_WALL blocked") +-- pokecrystal/constants/collision_constants.asm:19 +check(Permissions.isGrass(0x18), "COLL_TALL_GRASS is grass") +check(Permissions.isWater(0x29), "COLL_WATER is water") +check(Permissions.isWarpCollision(0x71), "COLL_DOOR is a warp") +check(Permissions.isLedge(0xa0), "COLL_HOP_DOWN is a ledge") +eq(Permissions.of(0xff), Permissions.WALL, "a missing coll reads as wall") + +local bare = {} +Roamers.init(bare) +eq(#bare.roamers, 3, "no cache -> the three-beast Gold roster") +check(Roamers.SPECIES ~= nil and #Roamers.SPECIES == 3, + "Roamers.SPECIES survives as the ROM-free fallback") + +-- pokecrystal/constants/script_constants.asm:256-257 +local pair = {} +Swarm.set(pair, "DARK_CAVE_VIOLET_ENTRANCE", 0) +Swarm.set(pair, "ROUTE_35", 1) +eq(pair.swarmMaps.DUNSPARCE, "DARK_CAVE_VIOLET_ENTRANCE", "SWARM_DUNSPARCE key") +eq(pair.swarmMaps.YANMA, "ROUTE_35", "SWARM_YANMA key, independently") +eq(Swarm.onMap(pair, "ROUTE_35"), "YANMA", "the Yanma map answers YANMA") +eq(Swarm.mapId(pair, "YANMA"), "ROUTE_35", "mapId takes a kind") +eq(Swarm.mapId(pair), "DARK_CAVE_VIOLET_ENTRANCE", "and defaults to Gold's key") +Swarm.timeEvents(pair, 100) +check(Swarm.timeEvents(pair, 101), "the next day ends the swarm") +eq(Swarm.onMap(pair, "ROUTE_35"), nil, "and both pairs are stranded") + +local legacy = { swarmMap = "ROUTE_35", dailyFlags = { swarm = true } } +eq(Swarm.mapId(legacy), "ROUTE_35", "an old scalar save answers mapId") +eq(Swarm.onMap(legacy, "ROUTE_35"), "DUNSPARCE", "normalized onto the Gold key") +eq(Swarm.onMap(legacy, "ROUTE_36"), nil, "another map is not the swarm map") +check(Swarm.entry(legacy, { swarmGrass = { ROUTE_35 = { "row" } }, grass = {} }, + "ROUTE_35", "grass") ~= nil, "and its swarm table still resolves") + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local mapsPath = cache .. "/data/generated/maps.lua" +local mapsFile = io.open(mapsPath, "r") +if not mapsFile then + check(true, "crystal cache absent : SKIP") + S.finish() + return +end +mapsFile:close() + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local maps = loadLua("data/generated/maps.lua") +local encounters = loadLua("data/generated/encounters.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local constants = loadLua("data/generated/constants.lua") + +check(maps ~= nil, "maps.lua loads") +check(encounters ~= nil, "encounters.lua loads") +check(tilesets ~= nil, "tilesets.lua loads") +check(constants ~= nil, "constants.lua loads") + +-- ---- 1. the maps Phase 1 has to reach + +local count = 0 +for _ in pairs(maps or {}) do count = count + 1 end +eq(count, 388, "the cache carries 388 maps") +for _, id in ipairs({ + "NEW_BARK_TOWN", "PLAYERS_HOUSE_1F", "PLAYERS_HOUSE_2F", "ELMS_LAB", + "ROUTE_29", "CHERRYGROVE_CITY", "ROUTE_30", "ROUTE_31", "VIOLET_CITY", + "SPROUT_TOWER_1F", "VIOLET_GYM", +}) do + check(maps[id] ~= nil, "the New Bark to Violet route has " .. id) +end +check(maps.BATTLE_TOWER_1F ~= nil, "and the Crystal-only Battle Tower") +-- pokegold/constants/map_constants.asm:152 +eq(maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil, + "the one map Crystal drops is absent") + +-- ---- 2. roamers (CT-4) + +check(type(encounters.roamMons) == "table", + "the cache emits encounters.roamMons") +eq(#encounters.roamMons, 2, "Crystal seeds two beasts, not three") +eq(encounters.roamMons[1].species, "RAIKOU", "slot 1 is Raikou") +eq(encounters.roamMons[1].map, "ROUTE_42", "on Route 42") +eq(encounters.roamMons[2].species, "ENTEI", "slot 2 is Entei") +eq(encounters.roamMons[2].map, "ROUTE_37", "on Route 37") +eq(encounters.roamMons[1].level, 40, "both start at level 40") +eq(encounters.roamMons[2].level, 40, "both start at level 40") + +local save = {} +Roamers.init(save, { encounters = encounters }) +eq(#save.roamers, 2, "Roamers.init over the cache builds two slots") +eq(save.roamers[1].species, "RAIKOU", "slot 1 Raikou") +eq(save.roamers[2].species, "ENTEI", "slot 2 Entei") +eq(save.roamers[3], nil, "and no Suicune slot") +eq(save.roamers[1].hp, 0, "hp is zeroed so stats regenerate on contact") + +local data = {} +Roamers.init(data, { data = { gen2Encounters = encounters } }) +eq(#data.roamers, 2, "the opts.data path resolves the same roster") + +eq(Roamers.checkEncounter(save, "ROUTE_38", false, function() return 3 end), nil, + "the Suicune slot roll finds nothing on a Crystal save") +local hit = Roamers.checkEncounter(save, "ROUTE_42", false, function() return 1 end) +check(hit and hit.species == "RAIKOU", "the Raikou roll still hits") + +-- ---- 3. the swarm tables the kind byte indexes + +check(encounters.swarmGrass ~= nil, "the cache carries swarmGrass") +check(encounters.swarmGrass.DARK_CAVE_VIOLET_ENTRANCE ~= nil, + "with the Dunsparce table") +check(encounters.swarmGrass.ROUTE_35 ~= nil, "and the Yanma table") + +-- ---- 4. the tileset palette-map bank +-- pokecrystal.sym 13:40e5 TilesetJohtoPalMap (pokegold.sym 02:40c7) +local johto = (tilesets or {}).TILESET_JOHTO +check(johto ~= nil, "TILESET_JOHTO is in the cache") +eq(johto and johto.palMap and johto.palMap.bank, 0x13, + "TilesetJohtoPalMap is read out of bank $13") +eq(johto and johto.palMap and johto.palMap.address, 0x40e5, + "at $40e5") +local kanto = (tilesets or {}).TILESET_KANTO +eq(kanto and kanto.palMap and kanto.palMap.bank, 0x13, + "TilesetKantoPalMap is bank $13 too") +eq(kanto and kanto.palMap and kanto.palMap.address, 0x4075, "at $4075") +check(johto and johto.tilePalettes and #johto.tilePalettes > 0, + "and the map decoded to a per-tile palette list") + +-- ---- 5. the special table the cache pins + +eq(#(constants.specialOrder or {}), 169, + "the cache's specialOrder has Crystal's 169 rows") + +S.finish() diff --git a/tests/drivers/crystal_battle_tower_shots.lua b/tests/drivers/crystal_battle_tower_shots.lua new file mode 100644 index 00000000..1db2af83 --- /dev/null +++ b/tests/drivers/crystal_battle_tower_shots.lua @@ -0,0 +1,173 @@ +-- The Battle Tower lobby, driven in a real game. +-- +-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower \ +-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love . +-- +-- Three sections, all on maps/BattleTower1F.asm's own extracted script: +-- A an illegal party, so _CheckForBattleTowerRules prints its refusals +-- B a legal party through Menu_ChallengeExplanationCancel and the room menu +-- C the walk to the elevator the chosen room starts +-- +-- Section B overrides the TryQuickSave stub for the length of the run: +-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85 +-- backs out of the whole challenge on that, so the desk cannot be walked +-- past without it. +local U = require("tests.drivers.util") + +local BattleTower = require("src.core.gen2.BattleTower") +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Vm = require("src.script.gen2.Vm") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + local function top() return game.stack:top() end + local function isMenu() return getmetatable(top()) == ScriptMenu end + local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local function party(spec) + local list = {} + for _, row in ipairs(spec) do + local m = Mon.new(game.data, row[1], row[2]) + m.item = row[3] + list[#list + 1] = m + end + game.save.party = list + end + + local function enterLobby() + while top() do tap("b", 2) end + assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F") + U.wait(40) + end + + -- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the + -- explanation offer, and the menu is the next command. + local function talkToMenu(limit) + for _ = 1, limit or 200 do + if isMenu() then return true end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + return false + end + + say("--- A: an illegal party at the desk") + party({ { "TYPHLOSION", 20 } }) + enterLobby() + shot("lobby") + ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel") + shot("challenge-menu") + ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel") + tap("a", 6) + + local pages = {} + for _ = 1, 40 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + shot("rules-" .. #pages) + end + if not world:busy() and top() == nil then break end + tap("a", 4) + end + local joined = table.concat(pages, " | ") + say("pages: " .. joined) + ok(joined:find("You're not ready", 1, true) ~= nil, + "_ExcuseMeYoureNotReadyText printed") + ok(joined:find("Only three", 1, true) ~= nil, + "_OnlyThreeMonMayBeEnteredText printed") + ok(joined:find("Please return when", 1, true) ~= nil, + "_BattleTowerReturnWhenReadyText printed") + + say("--- B: a legal party, the room menu, the walk out") + local savedQuickSave = Vm.SPECIALS.TryQuickSave + Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end + + party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" }, + { "MEGANIUM", 20, "GOLD_BERRY" } }) + enterLobby() + ok(talkToMenu(), "the desk reaches the menu again") + tap("a", 6) + + local sawSavePrompt = false + for _ = 1, 120 do + if isRoomMenu() then break end + if world.choicebox then + sawSavePrompt = true + shot("save-prompt") + tap("a", 6) + else + tap("a", 4) + end + end + ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first") + ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu") + shot("room-menu") + + if isRoomMenu() then + local screen = top() + ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS, + "no Hall of Fame, so four rooms are offered") + -- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917. + tap("a", 6) + ok(screen.phase == "message", "picking L:10 prints the level refusal") + shot("tops-this-level") + for _ = 1, 150 do + if screen.phase == "pick" then break end + U.wait(1) + end + ok(screen.phase == "pick", "and the menu comes back after the hold") + tap("up", 6) + shot("room-menu-l20") + tap("a", 6) + end + + for _ = 1, 240 do + if not world:busy() and top() == nil then break end + tap("a", 4) + end + local tower = BattleTower.state(game.save) + ok(tower.levelGroup ~= nil, "the level group survived the menu") + say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup) + .. " reward=" .. tostring(tower.reward) + .. " challenge=" .. tostring(tower.challenge) + .. " map=" .. tostring(world.map and world.map.id)) + ok(tower.reward ~= nil, + "BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out") + shot("after-desk") + + U.wait(180) + shot("elevator-or-hallway") + say("map after the walk: " .. tostring(world.map and world.map.id)) + + Vm.SPECIALS.TryQuickSave = savedQuickSave + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_boot_smoke.lua b/tests/drivers/crystal_boot_smoke.lua new file mode 100644 index 00000000..2d8d8fe3 --- /dev/null +++ b/tests/drivers/crystal_boot_smoke.lua @@ -0,0 +1,249 @@ +-- Smoke: the whole Crystal boot chain, with no driver shortcut. +-- +-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love . +-- +-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME +-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom -> +-- start menu -> outdoors -> a wild battle. Every step is asserted by the +-- class of the state on the stack, so a broken hand-off names the screen it +-- stalled on instead of just hanging. +local U = require("tests.drivers.util") + +local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local StartMenu = require("src.ui.gen2.StartMenu") +local TitleState = require("src.ui.gen2.TitleState") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot" + local log = io.open(os.getenv("POKEPORT_BOOT_LOG") + or (out .. "/boot.log"), "w") + + local function say(line) + print("[driver] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function bail(reason) + say("FAIL " .. reason) + if log then log:close() end + love.event.quit(1) + error(reason, 0) + end + + local function top() + return game.stack:top() + end + + local function isA(class) + local state = top() + return state ~= nil and getmetatable(state) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + local function pick(menu, value) + for i, item in ipairs(menu.list.items) do + if item.value == value then menu.list.index = i end + end + end + + if GameVersion.get() ~= "crystal" then + bail("booted " .. tostring(GameVersion.get()) .. ", not crystal") + end + if GameVersion.engine() ~= "crystal" then + bail("engine lineage is " .. tostring(GameVersion.engine())) + end + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + U.wait(10) + if not isA(CopyrightSplash) then + bail("boot did not start at the copyright splash (top " + .. tostring(top()) .. ")") + end + say("OK copyright") + U.shot(game, out .. "/01-copyright.png") + + waitFor("GameFreak presents", function() return isA(GameFreakPresents) end) + U.wait(90) + say("OK gamefreak") + U.shot(game, out .. "/02-gamefreak.png") + + -- ../pokecrystal/engine/movie/intro.asm:1 + waitFor("the Crystal intro", function() return isA(CrystalIntro) end) + U.wait(240) + say("OK crystal intro") + U.shot(game, out .. "/03-intro.png") + U.wait(240) + U.shot(game, out .. "/03b-intro.png") + tap("start") + + waitFor("the title screen", function() return isA(TitleState) end) + U.wait(90) + say("OK title") + U.shot(game, out .. "/04-title.png") + for _ = 1, 3 do tap("start", 3) end + + waitFor("the intro menu", function() return isA(MainMenu) end) + say("OK main menu") + U.shot(game, out .. "/05-mainmenu.png") + + local menu = top() + pick(menu, "option") + tap("a") + waitFor("the options screen", function() return isA(OptionsMenu) end) + U.shot(game, out .. "/06-options.png") + tap("b") + waitFor("the intro menu again", function() return isA(MainMenu) end) + + menu = top() + pick(menu, "new") + U.shot(game, out .. "/07-newgame.png") + tap("a") + + -- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which + -- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84). + waitFor("the gender screen", function() return isA(GenderSelect) end, 300) + say("OK gender select") + U.shot(game, out .. "/07b-gender.png") + tap("a") + + -- ../pokecrystal/engine/menus/intro_menu.asm:628 + waitFor("the clock screen", function() return isA(InitClock) end, 300) + say("OK init clock") + U.shot(game, out .. "/08-initclock.png") + for _ = 1, 60 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 300) + local oak = top() + say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies)) + if oak.demoSpecies ~= "WOOPER" then + bail("Oak's demo mon is " .. tostring(oak.demoSpecies) + .. ", Crystal's is WOOPER") + end + U.wait(60) + U.shot(game, out .. "/09-oak.png") + + local shotDemo = false + for _ = 1, 400 do + if isA(NamePick) then break end + if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then + U.wait(45) + U.shot(game, out .. "/10-wooper.png") + shotDemo = true + end + tap("a", 2) + end + if not isA(NamePick) then + bail("Oak speech never reached the name picker (top " + .. tostring(top()) .. ")") + end + say("OK name pick" .. (shotDemo and " (demo pic captured)" or "")) + -- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer + waitFor("the name menu to slide in", + function() return top().slide == nil end, 240) + U.shot(game, out .. "/11-namepick.png") + + local picker = top() + picker.cursor = 1 + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end) + local naming = top() + tap("a") + tap("right") + tap("a") + if naming.text ~= "AB" then + bail("typed name is " .. tostring(naming.text) .. ", expected AB") + end + say("OK naming screen") + U.shot(game, out .. "/12-naming.png") + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + + for _ = 1, 600 do + if game.phase == "play" and game.world and game.world.map then break end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + if game.save.player.name ~= "AB" then + bail("player name is " .. tostring(game.save.player.name)) + end + if game.world.map.id ~= "PLAYERS_HOUSE_2F" then + bail("new game started on " .. tostring(game.world.map.id) + .. ", expected the bedroom") + end + say("OK overworld, map = " .. game.world.map.id + .. ", name = " .. tostring(game.save.player.name)) + U.wait(20) + U.shot(game, out .. "/13-bedroom.png") + + tap("start") + waitFor("the start menu", function() return isA(StartMenu) end) + say("OK start menu") + U.shot(game, out .. "/14-startmenu.png") + tap("b") + waitFor("the overworld again", function() return top() == nil end) + + if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then + bail("could not load NEW_BARK_TOWN") + end + U.wait(30) + if game.world.map.id ~= "NEW_BARK_TOWN" then + bail("setMap landed on " .. tostring(game.world.map.id)) + end + local tileset = game.world.map.tileset or {} + say("OK outdoors, tileset = " .. tostring(tileset.id) + .. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {}))) + U.shot(game, out .. "/15-newbark.png") + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + if not (player and #player.moves > 0) then + bail("could not build a CYNDAQUIL from the Crystal pokemon.lua") + end + local wild = Mon.new(game.data, "WOOPER", 5) + if not wild then bail("could not build a wild WOOPER") end + game.save.party = { player } + if not game.world:startBattle({ wild = wild }) then + bail("startBattle failed") + end + U.wait(240) + say("OK battle, " .. player.species .. " L" .. player.level + .. " vs " .. wild.species .. " L" .. wild.level) + U.shot(game, out .. "/16-battle.png") + + say("PASS crystal boot chain in " .. out) + if log then log:close() end + love.event.quit(0) +end diff --git a/tests/drivers/crystal_f2_save_and_vars.lua b/tests/drivers/crystal_f2_save_and_vars.lua new file mode 100644 index 00000000..bb3e0113 --- /dev/null +++ b/tests/drivers/crystal_f2_save_and_vars.lua @@ -0,0 +1,227 @@ +-- TryQuickSave and the Crystal script VARs, in a real Crystal game. +-- +-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/f2 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love . +-- +-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the +-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the +-- overwrite prompt, the SAVING pages and a real file on disk -- and the +-- room menu it can only reach on a TRUE. +-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's +-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real +-- World, over a save and a reload, and then BuenaPrize spending it. +local U = require("tests.drivers.util") + +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local BuenaPassword = require("src.ui.gen2.BuenaPassword") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Specials = require("src.script.gen2.Specials") + +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2" + local fails, shots = 0, 0 + + local function say(line) + print("[driver] " .. line) + io.stdout:flush() + end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + local function top() return game.stack:top() end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + local save = game.save + + ok(Specials.STUB_REASONS.TryQuickSave == nil, + "TryQuickSave is a handler, not a stub") + ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil, + "SampleKenjiBreakCountdown is a handler too") + + --------------------------------------------------------------- section B + say("--- B: Buena's Blue Card, over a save and a reload") + + ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F") + U.wait(30) + + local crystal = Save.crystalState(save) + crystal.buenaPassword.balance = 0 + crystal.buenaPassword.word = 0x00 + crystal.buenaPassword.day = nil + + -- The three rows the correct-answer arm runs, straight off the extracted + -- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar. + local function awardPoint() + local started = world.vm:start({ + { op = "readvar", var = VAR_BLUECARDBALANCE }, + { op = "addval", args = { 1 } }, + { op = "writevar", var = VAR_BLUECARDBALANCE }, + }) + ok(started == true, "the award rows started (vm idle)") + for _ = 1, 120 do + if not world.vm:running() then break end + U.wait(1) + end + end + for _ = 1, 3 do awardPoint() end + say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE))) + ok(world:readVar(VAR_BLUECARDBALANCE) == 3, + "three correct passwords are three points") + ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal") + + -- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the + -- day's roll there through the same writevar path. + world:writeVar(VAR_BUENASPASSWORD, 0x24) + ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back") + + -- The map load that used to eat it: scriptVars is rebuilt empty at + -- World.lua:579. + ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs") + U.wait(20) + ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it") + + ok(game:writeSave() ~= false, "saved with the balance on it") + local reloaded = Save.load(save.version) + local reloadedBalance = reloaded and reloaded.crystal + and reloaded.crystal.buenaPassword + and reloaded.crystal.buenaPassword.balance + say("reloaded blue card balance: " .. tostring(reloadedBalance)) + ok(reloadedBalance == 3, "and it survives a save and a reload") + ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24, + "so does the password") + + -- BuenaPrize, which could never dispense anything on a balance of 0. + -- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points. + save.inventory = save.inventory or {} + save.inventory.ULTRA_BALL = nil + local prizeIndex + for index, name in ipairs(world.vm.specialOrder or {}) do + if name == "BuenaPrize" then prizeIndex = index - 1 break end + end + ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder") + world.vm:start({ { op = "special", id = prizeIndex } }) + local sawPrizeMenu = false + for _ = 1, 300 do + if not world.vm:running() and top() == nil then break end + if getmetatable(top()) == BuenaPassword then + if not sawPrizeMenu then + sawPrizeMenu = true + say("prize menu balance shown: " .. tostring(top().balance)) + shot("prize-menu") + tap("a", 6) -- ULTRA BALL + else + tap("b", 6) -- second pass: leave the shop + end + else + tap("a", 4) + end + end + ok(sawPrizeMenu, "BuenaPrize opened its list") + say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL) + .. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE))) + ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over") + ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent") + shot("after-prize") + + --------------------------------------------------------------- section A + say("--- A: the Battle Tower desk, with the real TryQuickSave") + + -- A file has to already exist for AskOverwriteSaveFile to have anything to + -- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`). + ok(game:writeSave() ~= false, "a first save is on disk") + ok(Save.exists(save.version), "Save.exists agrees") + + save.party = {} + for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 }, + { "MEGANIUM", 20 } }) do + local mon = Mon.new(game.data, row[1], row[2]) + save.party[#save.party + 1] = mon + end + + for _ = 1, 40 do + if top() == nil then break end + tap("b", 2) + end + ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F") + U.wait(40) + shot("tower-lobby") + + local function isMenu() return getmetatable(top()) == ScriptMenu end + local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end + + local reached = false + for _ = 1, 200 do + if isMenu() then reached = true break end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel") + tap("a", 6) -- CHALLENGE + + -- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and + -- then AskOverwriteSaveFile's own, which the stub never asked. + local prompts, pages = 0, {} + for _ = 1, 240 do + if isRoomMenu() then break end + if world.choicebox then + prompts = prompts + 1 + shot("prompt-" .. prompts) + say("prompt " .. prompts .. ": " .. tostring(world.lastText)) + tap("a", 6) + else + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + end + tap("a", 4) + end + end + say("pages: " .. table.concat(pages, " | ")) + ok(prompts >= 2, + "AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)") + local joined = table.concat(pages, " | ") + ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown") + ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText") + ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened") + shot("room-menu") + + local onDisk = Save.load(save.version) + ok(onDisk ~= nil, "the challenge's own save reached disk") + ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F", + "and it was written from the lobby: " + .. tostring(onDisk and onDisk.position and onDisk.position.map)) + + -- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the + -- desk is forward: pick a room and let the walk to the elevator finish. + for _ = 1, 400 do + if not world:busy() and top() == nil then break end + tap("a", 4) + end + say("map after the desk: " .. tostring(world.map and world.map.id)) + for _ = 1, 40 do + if top() == nil then break end + game.stack:pop() + end + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_gender_flag_shots.lua b/tests/drivers/crystal_gender_flag_shots.lua new file mode 100644 index 00000000..a548283c --- /dev/null +++ b/tests/drivers/crystal_gender_flag_shots.lua @@ -0,0 +1,260 @@ +-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered +-- pictures and the Crystal surf refusal. Not a suite; a shot harness. +-- +-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \ +-- POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love . +local U = require("tests.drivers.util") + +local BattleState = require("src.ui.gen2.BattleState") +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local MainMenu = require("src.ui.gen2.MainMenu") +local Mon = require("src.battle.gen2.Mon") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local Screens = require("src.ui.Screens") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender" + local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower() + local log = io.open(out .. "/driver.log", "w") + + local function say(line) + print("[u2] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function done(code) + if log then log:close() end + love.event.quit(code) + if code ~= 0 then error("driver failed", 0) end + coroutine.yield() + end + + local function bail(reason) + say("FAIL " .. reason) + done(1) + end + + local function top() return game.stack:top() end + local function isA(class) + local s = top() + return s ~= nil and getmetatable(s) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return true end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc)) + + ------------------------------------------------------------------ new game + U.wait(10) + for _ = 1, 1200 do + if isA(MainMenu) then break end + tap("start", 2) + end + waitFor("the main menu", function() return isA(MainMenu) end, 600) + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + local gendered = false + for _ = 1, 400 do + if isA(GenderSelect) then gendered = true break end + if isA(OakSpeech) or isA(NamePick) then break end + U.wait(1) + end + if GameVersion.engine() == "crystal" then + if not gendered then bail("Crystal never offered InitGender") end + local pick = top() + pick.cursor = (want == "girl") and 2 or 1 + U.shot(game, out .. "/00-gender.png") + say("OK gender screen, choosing " .. want) + tap("a") + else + if gendered then bail("Gold offered a gender prompt; it has none") end + say("OK no gender prompt on " .. GameVersion.get()) + end + + for _ = 1, 900 do + if game.phase == "play" and game.world and game.world.map then break end + if isA(NamingScreen) then + local naming = top() + naming.row = naming:bottomRow() + naming.col = 6 + end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + local world = game.world + local save = game.save + say("OK overworld, name=" .. tostring(save.player.name) + .. " gender=" .. tostring(save.player.gender) + .. " sprite=" .. tostring(world:playerSpriteName())) + + ------------------------------------------------------ the engine flag itself + local id = FieldMoves.FEMALE_FLAG + say("FieldMoves.FEMALE_FLAG=" .. tostring(id)) + if GameVersion.engine() == "crystal" then + if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end + local reads = world:engineFlag(id) + say("world:engineFlag(" .. id .. ")=" .. tostring(reads)) + if reads ~= (want == "girl") then + bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads)) + end + elseif id ~= nil then + bail("Gold bound a female flag id: " .. tostring(id)) + end + + ------------------------------------------------------------- the three sites + local sites = { + { "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" }, + { "ROUTE_37", 5, 9, "up", "02-route37" }, + { "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" }, + } + for _, site in ipairs(sites) do + local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5] + if world.maps and world.maps[mapId] then + world:setMap(mapId, x, y, facing) + U.wait(40) + U.shot(game, out .. "/" .. name .. ".png") + local names = {} + for _, npc in ipairs(world.npcs or {}) do + names[#names + 1] = tostring(npc.def and npc.def.sprite) + end + say(name .. " map=" .. tostring(world.map.id) + .. " objects=" .. table.concat(names, ",")) + else + say("SKIP " .. mapId .. ": not in this cache") + end + end + + ------------------------------------------------------------------- the pack + world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(20) + save.inventory = save.inventory or {} + save.inventory.POTION = 3 + Screens.push(game, "Gen2PackMenu", { save = save, world = {}, + onClose = function() game.stack:pop() end }) + U.wait(20) + U.shot(game, out .. "/04-pack.png") + local packGfx = top() and top().gfx + local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes + local menuGfx = game.data.gen2MenuGfx or {} + local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale + say("pack palettes are the female set: " + .. tostring(pals ~= nil and pals == femalePals)) + tap("b") + U.wait(10) + while top() do game.stack:pop() end + + ------------------------------------------------------------ battle back pic + local hud = menuGfx.battleHud or {} + say("battleHud.playerBack=" .. tostring(hud.playerBack) + .. " playerBackFemale=" .. tostring(hud.playerBackFemale)) + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) }) + local battle + for _ = 1, 600 do + if getmetatable(top()) == BattleState then battle = top() break end + U.wait(1) + end + if not battle then bail("startBattle never reached the battle screen") end + -- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the + -- back pic is what the shot catches. + for _ = 1, 90 do + battle.showPlayerTrainer = true + U.wait(1) + end + battle.showPlayerTrainer = true + U.shot(game, out .. "/05-battle-backpic.png") + say("battle backpic path=" .. tostring(battle.playerBackPath)) + while top() do game.stack:pop() end + U.wait(10) + + ------------------------------------------------------------- trainer card + Screens.push(game, "Gen2TrainerCard", { onClose = function() + game.stack:pop() end }) + U.wait(30) + U.shot(game, out .. "/06-trainercard.png") + say("trainer card female arm=" .. tostring(top() and top().female)) + tap("b") + U.wait(10) + while top() do game.stack:pop() end + + ------------------------------------------------------------- hall of fame + save.hallOfFame = nil + Screens.push(game, "Gen2HallOfFame", { + save = save, mode = "induct", text = world.text, + entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12, + nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } }, + onDone = function() game.stack:pop() end }) + U.wait(20) + local hof = top() + if getmetatable(hof) ~= HallOfFame then + bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")") + end + -- The ceremony walks its own phases; freeze it so the two player cards can + -- be posed and shot. + hof.update = function() end + hof.scx, hof.scy = 0, 0 + hof.phase = "player" + U.shot(game, out .. "/07-hof-trainerpic.png") + hof.phase = "playerBack" + U.shot(game, out .. "/08-hof-backpic.png") + say("hof backpic=" .. tostring(hof.playerBackPath) + .. " trainerPic=" .. tostring(hof.trainerPicPath)) + while top() do game.stack:pop() end + U.wait(10) + + ------------------------------------------------------------- surf onto NPC + -- + -- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not + -- (../pokecrystal/engine/events/overworld.asm:364). + local ctx = { + save = { player = { badges = { FOG = true } } }, + mon = { species = "LAPRAS" }, + facing = "down", + facingColl = 0x29, + playerColl = 0x00, + playerState = FieldMoves.PLAYER_NORMAL, + facingObject = { id = "an NPC standing on the water" }, + } + local refused = FieldMoves.surfFromMenu(ctx) + say("surf onto an NPC: ok=" .. tostring(refused.ok) + .. " text=" .. tostring(refused.text)) + local shouldRefuse = GameVersion.fixes().surfOntoNpc == true + if (refused.ok ~= true) ~= shouldRefuse then + bail("surf-onto-NPC answered ok=" .. tostring(refused.ok) + .. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse)) + end + ctx.facingObject = nil + if not FieldMoves.surfFromMenu(ctx).ok then + bail("open water refused SURF") + end + + say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out) + done(0) +end diff --git a/tests/drivers/crystal_gender_shots.lua b/tests/drivers/crystal_gender_shots.lua new file mode 100644 index 00000000..41db7f19 --- /dev/null +++ b/tests/drivers/crystal_gender_shots.lua @@ -0,0 +1,234 @@ +-- Crystal's InitGender screen, end to end, and the six things that follow from +-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold, +-- which must never see the screen at all). +-- +-- POKEPORT_IDENTITY= POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love . +local U = require("tests.drivers.util") + +local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local Screens = require("src.ui.Screens") + +return function(game) + local want = os.getenv("POKEPORT_GENDER") or "girl" + local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want) + local log = io.open(out .. "/driver.log", "w") + + local function say(line) + print("[driver] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function bail(reason) + say("FAIL " .. reason) + if log then log:close() end + love.event.quit(1) + error(reason, 0) + end + + local function top() return game.stack:top() end + local function isA(class) + local state = top() + return state ~= nil and getmetatable(state) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + local function eq(got, wanted, label) + if got ~= wanted then + bail(("%s: got %s, want %s"):format(label, tostring(got), + tostring(wanted))) + end + say("OK " .. label .. " = " .. tostring(got)) + end + + say("version=" .. tostring(GameVersion.get()) + .. " engine=" .. tostring(GameVersion.engine()) + .. " gender=" .. want) + + -- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's. + for _ = 1, 900 do + if isA(MainMenu) then break end + tap("start", 2) + end + if not isA(MainMenu) then + for _ = 1, 400 do + if isA(MainMenu) then break end + tap("a", 2) + end + end + waitFor("the intro menu", function() return isA(MainMenu) end) + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + if want == "none" then + -- Gold and Silver: PlayerProfileSetup has no InitGender to farcall. + waitFor("the clock screen", function() return isA(InitClock) end, 400) + if isA(GenderSelect) then bail("Gold put up the gender screen") end + say("OK no gender screen; NEW GAME went straight to the clock") + for _ = 1, 200 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 400) + local speech = top() + for _, step in ipairs(speech.steps or {}) do + if step.kind == "gender" then bail("Gold's speech grew a gender beat") end + end + eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock", + "the first beat") + U.wait(40) + U.shot(game, out .. "/01-oak.png") + eq(game.save.player.gender, "male", "save.player.gender") + say("PASS gold has no gender prompt") + if log then log:close() end + love.event.quit(0) + return + end + + waitFor("the gender screen", function() return isA(GenderSelect) end, 600) + local gender = top() + U.wait(20) + say("OK gender screen, cursor = " .. tostring(gender.cursor)) + U.shot(game, out .. "/01-genderselect.png") + if want == "girl" then + tap("down") + U.wait(10) + U.shot(game, out .. "/02-genderselect-girl.png") + else + U.shot(game, out .. "/02-genderselect-boy.png") + end + tap("a") + + waitFor("the clock screen", function() return isA(InitClock) end, 400) + eq(game.save.player.gender, want == "girl" and "female" or "male", + "wPlayerGender after the answer") + eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", + "NamePlayer's InitName default") + + for _ = 1, 200 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 400) + local speech = top() + eq(speech.steps[1].id, "gender_select", "the beat the speech opened on") + + local shotPic = false + for _ = 1, 500 do + if isA(NamePick) then break end + if not shotPic and isA(OakSpeech) then + local at = top().steps and top().steps[top().step] + if at and at.id == "ask_player_name" then + U.wait(45) + local wanted = (want == "girl") and top().playerPicFemale + or top().playerPic + if top().pic ~= wanted then + bail("DrawIntroPlayerPic put up the wrong pic") + end + U.shot(game, out .. "/03-intropic.png") + shotPic = true + end + end + tap("a", 2) + end + waitFor("the name picker", function() return isA(NamePick) end, 200) + waitFor("the name menu to slide in", + function() return top().slide == nil end, 240) + local picker = top() + eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1") + eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2") + U.shot(game, out .. "/04-namepick.png") + + picker.cursor = 1 + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end) + local naming = top() + eq(naming.gender, want == "girl" and "female" or "male", + "the keyboard's header icon gender") + U.wait(20) + U.shot(game, out .. "/05-naming.png") + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + + for _ = 1, 700 do + if game.phase == "play" and game.world and game.world.map then break end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom") + eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", + "the name that reached the save") + local sprite = game.world.player and game.world.player.spriteDef + eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender), + "the overworld sheet") + U.wait(20) + U.shot(game, out .. "/06-bedroom.png") + + -- Walk a step so the sheet is seen mid-stride rather than only standing. + U.hold(game, "down", 20) + U.wait(20) + U.shot(game, out .. "/07-bedroom-walk.png") + + -- The card, opened directly: the START-menu route is Gen2StartMenu's and is + -- already covered by crystal_boot_smoke. + game.save.player.badges = { ZEPHYR = true, HIVE = true } + local card = Screens.push(game, "Gen2TrainerCard", + { save = game.save, onClose = function() end }) + if getmetatable(card) ~= TrainerCard then bail("the card did not open") end + eq(card.female, want == "girl", "GetCardPic picked KrisCardPic") + U.wait(30) + U.shot(game, out .. "/08-trainercard.png") + tap("right") + U.wait(20) + U.shot(game, out .. "/09-trainercard-badges.png") + game.stack:pop() + + local gear = Screens.push(game, "Gen2Pokegear", { save = game.save }) + U.wait(30) + local pals = gear.pals and gear:pals() + local femalePals = gear.gfx and gear.gfx.palettesFemale + if want == "girl" and femalePals and pals ~= femalePals then + bail("the gear kept MalePokegearPals for Kris") + end + if want == "boy" and femalePals and pals == femalePals then + bail("the gear took FemalePokegearPals for Chris") + end + say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male")) + U.shot(game, out .. "/10-pokegear.png") + game.stack:pop() + + say("PASS crystal gender run (" .. want .. ") in " .. out) + if log then log:close() end + love.event.quit(0) +end diff --git a/tests/drivers/crystal_intro_shots.lua b/tests/drivers/crystal_intro_shots.lua new file mode 100644 index 00000000..fcc5d096 --- /dev/null +++ b/tests/drivers/crystal_intro_shots.lua @@ -0,0 +1,117 @@ +local U = require("tests.drivers.util") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local TitleState = require("src.ui.gen2.TitleState") + +local INTERVAL = 20 +local LIMIT = 4000 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-intro" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") + or INTERVAL + + U.wait(30) + local assets = game.data and game.data.gen2Intro + if not (assets and assets.acts) then + U.log("SKIP no crystal intro acts in this cache -- re-import crystal") + return + end + + local finished = false + local intro = CrystalIntro.new(game, { + onDone = function() finished = true end, + }) + game.stack:clear() + game.stack:push(intro) + + local shots, scene = 0, 0 + while not finished and intro.frames < LIMIT do + U.wait(interval) + if intro.scene ~= scene then + scene = intro.scene + U.log(("scene %d at frame %d (scx=%02x scy=%02x)") + :format(scene, intro.frames, intro.scx % 256, intro.scy % 256)) + end + if not finished then + U.shot(game, ("%s/intro-%04d-scene%02d.png") + :format(out, intro.frames, intro.scene)) + shots = shots + 1 + end + end + assert(finished, + "the movie never reached IntroScene28 (frame " .. intro.frames .. ")") + U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames)) + + local skipped = false + local intro2 = CrystalIntro.new(game, { + onDone = function() skipped = true end, + }) + game.stack:clear() + game.stack:push(intro2) + U.wait(45) + U.tap(game, "b") + U.wait(3) + assert(skipped and intro2.skipped, "a button did not skip the intro") + U.log("skip via B verified at frame " .. intro2.frames) + + game:showTitle() + U.wait(1) + local title = game.stack:top() + assert(getmetatable(title) == TitleState, + "showTitle left " .. tostring(title) .. " on the stack") + assert(#title.suicuneColor == 4, + "title.lua has no 4-frame Suicune set -- stale cache?") + assert(title.entrance, "title.lua has no entrance block -- stale cache?") + assert(title.suicuneX == 48 and title.suicuneY == 96, + ("Suicune is at (%s, %s), expected (48, 96)") + :format(tostring(title.suicuneX), tostring(title.suicuneY))) + + for i = 1, 4 do + U.shot(game, ("%s/title-entrance-%d.png"):format(out, i)) + U.wait(4) + end + for _ = 1, 90 do + if title.entranceScx == 0 then break end + U.wait(1) + end + assert(title.entranceScx == 0, "the entrance never landed") + assert(title.gemY == title.gemRestY, + ("gem y %s did not land at %s with the entrance") + :format(tostring(title.gemY), tostring(title.gemRestY))) + U.wait(2) + for i = 1, 5 do + U.shot(game, ("%s/title-suicune-%d.png"):format(out, i)) + U.wait(6) + end + + if love.window and love.window.setMode then + for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do + love.window.setMode(shape[1], shape[2], { resizable = true }) + U.wait(3) + U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2])) + end + love.window.setMode(1280, 720, { resizable = true }) + end + + local intro3 = CrystalIntro.new(game, {}) + game.stack:clear() + game.stack:push(intro3) + local function runTo(target, extra) + for _ = 1, LIMIT do + if intro3.scene >= target then break end + U.wait(5) + end + assert(intro3.scene >= target, + "widescreen pass never reached scene " .. target) + U.wait(extra) + end + runTo(4, 40) + U.shot(game, out .. "/intro-wide-scene04.png") + runTo(10, 60) + U.shot(game, out .. "/intro-wide-scene10.png") + runTo(18, 20) + U.shot(game, out .. "/intro-wide-scene18.png") + intro3:skip() + + U.log(("PASS crystal intro + title shots in %s"):format(out)) +end diff --git a/tests/drivers/crystal_story_shots.lua b/tests/drivers/crystal_story_shots.lua new file mode 100644 index 00000000..f7515610 --- /dev/null +++ b/tests/drivers/crystal_story_shots.lua @@ -0,0 +1,278 @@ +-- The Crystal story specials, driven in a real game. +-- +-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \ +-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love . +-- +-- Four sections, each entered by putting the player on the real map and +-- letting the extracted script run: +-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees +-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi +-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset +-- D DAY_CARE GiveOddEgg +local U = require("tests.drivers.util") + +local BattleState = require("src.ui.gen2.BattleState") +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local Roamers = require("src.core.gen2.Roamers") +local Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function top() return game.stack:top() end + local function battleState() + local st = top() + if st ~= nil and getmetatable(st) == BattleState then return st end + return nil + end + local function inBattle() + local st = battleState() + return st and st.battle or nil + end + + local function waitFor(pred, frames) + for _ = 1, frames or 1200 do + if pred() then return true end + U.wait(1) + end + return false + end + + local world + + -- Mash A until the script is finished and the stack is bare again. The + -- naming screen gets B: `givepoke` ends in Specials.askNickname + -- (src/script/gen2/Specials.lua:382) and mashing A there types letters + -- forever instead of leaving. + -- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by + -- putting the cursor on the bottom row's confirm cell. Confirming a blank + -- name is the cart's own "no nickname" (home/string.asm:6 _InitString). + local function mash() + local st = top() + if getmetatable(st) == NamingScreen then + st.row, st.col = st:bottomRow(), 6 + end + tap("a", 2) + end + + local function settle(frames) + for _ = 1, frames or 3000 do + if not world:busy() and top() == nil then return true end + mash() + end + local meta, name = getmetatable(top()), "?" + for id, mod in pairs(package.loaded) do + if mod == meta then name = id end + end + say((" stuck on %s (phase=%s) busy=%s"):format(name, + tostring(top() and top().phase), tostring(world:busy()))) + return false + end + + -- Stand one cell below the object carrying `scriptKey`, facing it. + local function standBelow(mapId, scriptKey) + world:setMap(mapId, 1, 1, "down") + U.wait(10) + local tx, ty + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.scriptKey == scriptKey then + tx, ty = npc.cellX, npc.cellY + end + end + if not tx then return false end + world:setMap(mapId, tx, ty + 1, "up") + U.wait(30) + return world.player.cellX == tx and world.player.cellY == ty + 1 + end + + local function moveIds(mon) + local list = {} + for _, move in ipairs((mon and mon.moves) or {}) do + list[#list + 1] = move.id + end + return table.concat(list, ",") + end + + U.wait(60) + world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local save = game.save + save.player = save.player or {} + save.player.name = save.player.name or "CHRIS" + save.player.id = save.player.id or 30000 + save.inventory = save.inventory or {} + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + + -- ---- A TIN_TOWER_1F ---------------------------------------------------- + -- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run + -- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22. + say("A Tin Tower") + say(" crystal AlwaysFleeMons SUICUNE = " + .. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE) + .. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE)) + world.mapScenes.TIN_TOWER_1F = 0 + world:setMap("TIN_TOWER_1F", 9, 14, "up") + U.wait(20) + U.shot(game, out .. "/a1-tintower-enter.png") + + ok(waitFor(function() return world:busy() end, 600), + "the scene script started on map entry") + U.wait(120) + U.shot(game, out .. "/a2-beasts.png") + ok(waitFor(function() return inBattle() ~= nil end, 3000), + "the Tin Tower scene reached a battle") + local battle = inBattle() + if battle then + U.wait(90) + U.shot(game, out .. "/a3-suicune-battle.png") + ok(battle.enemy and battle.enemy.species == "SUICUNE", + "the wild mon is SUICUNE (got " .. + tostring(battle.enemy and battle.enemy.species) .. ")") + ok((battle.enemy and battle.enemy.level) == 40, "at level 40") + -- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off + -- AlwaysFleeMons it has to survive the two random gates as well. + local fled = 0 + for _ = 1, 500 do + if battle:tryEnemyFlee() then fled = fled + 1 end + end + ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")") + ok(battle.outcome == nil, "and the battle is still live") + battle.outcome = "run" + local st = battleState() + if st then st:finishBattle() end + end + ok(settle(2000), "the Tin Tower script ran to its end") + + -- ---- B ILEX_FOREST ----------------------------------------------------- + -- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP + -- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on + -- a retail cart neither can be reached, so both are set here the way the + -- Virtual Console wrapper sets sGSBallFlag + -- (../pokecrystal/engine/menus/save.asm:168). + say("B Ilex Forest shrine") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + Save.crystalState(save).gsBall = "have" + save.inventory.GS_BALL = 1 + save.inventory.MASTER_BALL = 5 + world:setMap("ILEX_FOREST", 8, 23, "up") + U.wait(40) + world.events:set(192, true) + U.shot(game, out .. "/b1-shrine.png") + + tap("a", 30) + U.shot(game, out .. "/b2-shrine-prompt.png") + -- The prompt is the script's own yesorno; A takes YES. + ok(waitFor(function() + if inBattle() then return true end + tap("a", 3) + return false + end, 400), "the shrine script reached a battle") + local celebi = inBattle() + if celebi then + U.wait(90) + U.shot(game, out .. "/b3-celebi.png") + ok(celebi.enemy and celebi.enemy.species == "CELEBI", + "the wild mon is CELEBI (got " .. + tostring(celebi.enemy and celebi.enemy.species) .. ")") + ok(celebi.battleType == 11, + "the battle carries BATTLETYPE_CELEBI (got " + .. tostring(celebi.battleType) .. ")") + -- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read. + local thrown = false + for _ = 1, 900 do + local st = battleState() + if not st then break end + if not thrown and st.phase == "menu" then + st:useItem("MASTER_BALL") + thrown = true + U.wait(60) + U.shot(game, out .. "/b4-masterball.png") + end + mash() + end + end + ok(settle(2000), "the shrine script ran to its end") + U.shot(game, out .. "/b5-after-celebi.png") + local caught = false + for _, mon in ipairs(save.party) do + if mon.species == "CELEBI" then caught = true end + end + ok(caught, "Celebi is in the party") + ok(Save.crystalState(save).celebiCaught == true, + "CheckCaughtCelebi recorded the catch") + ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken") + + -- ---- C DRAGON_SHRINE --------------------------------------------------- + -- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose + -- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent + -- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`. + say("C Dragon Shrine") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + -- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is + -- the Dragon Master quiz cutscene, which is not what this section measures. + world.mapScenes.DRAGON_SHRINE = 1 + ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder") + U.shot(game, out .. "/c1-shrine.png") + tap("a", 30) + U.shot(game, out .. "/c2-elder.png") + ok(settle(2000), "the elder's script ran to its end") + U.shot(game, out .. "/c3-dratini.png") + local dratini + for _, mon in ipairs(save.party) do + if mon.species == "DRATINI" then dratini = mon end + end + ok(dratini ~= nil, "the elder handed over a DRATINI") + if dratini then + say(" moves = " .. moveIds(dratini)) + ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED", + "with .Moveset0, Extremespeed and all") + ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP") + end + + -- ---- D DAY_CARE -------------------------------------------------------- + -- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose + -- `special GiveOddEgg` is at :33. + say("D Day Care") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + save.inventory.EGG_TICKET = 1 + ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man") + U.shot(game, out .. "/d1-daycare.png") + tap("a", 30) + U.shot(game, out .. "/d2-gramps.png") + ok(settle(2000), "the Day-Care Man's script ran to its end") + U.shot(game, out .. "/d3-oddegg.png") + local egg = save.party[2] + ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party") + if egg then + say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format( + tostring(egg.species), tostring(egg.ot), tostring(egg.otId), + tostring(egg.eggSteps), moveIds(egg))) + ok(egg.ot == "ODD", "with OT ODD") + ok(egg.eggSteps == 20, "and 20 hatch cycles") + end + ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed") + + say(fails == 0 and ("PASS crystal story in " .. out) + or ("FAIL " .. fails .. " checks, shots in " .. out)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_tower_battle_f1.lua b/tests/drivers/crystal_tower_battle_f1.lua new file mode 100644 index 00000000..c35cf272 --- /dev/null +++ b/tests/drivers/crystal_tower_battle_f1.lua @@ -0,0 +1,277 @@ +-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the +-- opponent draw, the walk-in, the battle, and the counter the room script +-- reads back. +-- +-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower-battle \ +-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love . +-- +-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and +-- World:startBattle does not forward `battleTower` into Battle.new, so the +-- three seams are shimmed here for the length of the run. The bodies are +-- exactly what belongs in World:specialHooks and World:startBattle. +local U = require("tests.drivers.util") + +local Battle = require("src.battle.gen2.Battle") +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") +local World = require("src.world.gen2.World") + +-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438), +-- so the seams go on the live instance. +local function shimWorldSeams(world) + local towerBattle = false + local baseNew = Battle.new + Battle.new = function(opts) + if towerBattle then opts.battleTower = true end + return baseNew(opts) + end + + local baseStartBattle = World.startBattle + world.startBattle = function(self, opts, onDone) + towerBattle = (opts and opts.battleTower) and true or false + local started = baseStartBattle(self, opts, onDone) + towerBattle = false + return started + end + + local hooks = world.vm.specials + hooks.startTowerBattle = function(trainer, onDone) + return world:startBattle({ trainer = trainer, battleTower = true }, onDone) + end + hooks.setObjectSprite = function(objectId, spriteName) + local index = (objectId or 0) - 1 + local def = world.map and world.map.def + local obj = def and def.objects and def.objects[index] + local sheet = world.sprites and world.sprites[spriteName] + if not (obj and sheet) then return false end + obj.sprite = spriteName + local npc = world:objectEntity(objectId) + if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end + world:rebuildPeople({ seamless = true }) + return true + end +end + +-- ---- the run -------------------------------------------------------------- + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + shimWorldSeams(world) + ok(type(world.vm.specials.startTowerBattle) == "function", + "the shimmed startTowerBattle hook reached the VM") + ok(type(world.vm.specials.setObjectSprite) == "function", + "and so did setObjectSprite") + + local roster = BattleTower.roster(game.data) + ok(roster ~= nil, "trainers.lua carries the Battle Tower roster") + if roster then + say(("roster: %d trainers, %d groups of %d, sample ceiling %d") + :format(#roster.trainers, roster.levelGroups, roster.uniqueMon, + roster.sampleTrainers)) + end + + -- Three legal mons over the L10 room, each with ONE strong move so a blind + -- A-press run cannot pick a status move for a hundred turns. + local party = {} + for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" }, + { "FERALIGATR", 60, "SURF", "BERRY" }, + { "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do + local def = game.data.moves[row[3]] + local mon = Mon.new(game.data, row[1], row[2], { + moves = { { id = row[3], pp = def.pp, maxPp = def.pp } }, + }) + mon.item = row[4] + party[#party + 1] = mon + end + game.save.party = party + + -- The desk's own SAVELEVELGROUP write, so the room the script walks into is + -- the L10 one (battle_tower.asm:1129-1141). + local tower = BattleTower.state(game.save) + tower.levelGroup = 1 + tower.streak = 0 + tower.trainers = {} + tower.challenge = BattleTower.NO_CHALLENGE + + while game.stack:top() do game.stack:pop() end + assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"), + "no BATTLE_TOWER_BATTLE_ROOM in the cache") + U.wait(30) + shot("room-entered") + + -- The scene script walks the player in, draws the opponent and starts the + -- battle; nothing here presses anything until a text box wants it. + local sawOpponent, sawBattle = false, false + for _ = 1, 900 do + local vm = world.vm + if vm and vm.btOpponent and not sawOpponent then + sawOpponent = true + say(("opponent: %s %s (row %d), sprite %s, group %d") + :format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name), + vm.btOpponent.index, tostring(vm.btOpponent.sprite), + vm.btOpponent.group)) + for slot, mon in ipairs(vm.btOpponent.rows) do + say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level, + tostring(mon.item))) + end + U.wait(20) + shot("opponent-walked-in") + end + if world.battleActive then + sawBattle = true + break + end + if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end + end + ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one") + ok(sawBattle, "special BattleTowerBattle pushed the battle screen") + + local screen, battle = nil, nil + if sawBattle then + U.wait(90) + shot("battle-open") + screen = game.stack:top() + battle = screen and screen.battle + ok(battle ~= nil, "the battle screen owns a Battle") + if battle then + ok(battle.inBattleTowerBattle == true, + "wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off") + say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name)) + say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species) + .. " L" .. tostring(battle.enemy and battle.enemy.level)) + ok(battle.trainer ~= nil and #battle.trainer.party == 3, + "with a three-mon Tower party") + end + end + + -- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest + -- move with PP left. + local function hardestMove(mon) + local best, bestPower = 1, -1 + for index, move in ipairs((mon and mon.moves) or {}) do + local def = game.data.moves and game.data.moves[move.id] + local power = (def and def.power) or 0 + if (move.pp or 0) > 0 and power > bestPower then + best, bestPower = index, power + end + end + return best + end + + -- PP topped up between turns: an unattended run otherwise Struggles itself + -- to death long before the third opponent mon is down. + local function refill(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + move.pp = move.maxPp or move.pp + end + end + + local menus = 0 + for _ = 1, 900 do + if not world.battleActive or (battle and battle.over) then break end + local phase = screen and screen.phase + if phase == "menu" then + menus = menus + 1 + if menus == 1 then shot("battle-menu") end + refill(battle and battle.player) + screen.menuIndex = 1 + tap("a", 4) + elseif phase == "moves" then + screen.menuIndex = hardestMove(battle and battle.player) + tap("a", 4) + elseif phase == "submenu" then + -- A forced switch cannot be cancelled, so walk off the fainted lead. + tap("down", 3) + tap("a", 4) + else + tap("a", 3) + end + end + if battle then + say("battle over=" .. tostring(battle.over) + .. " outcome=" .. tostring(battle.outcome) + .. " phase=" .. tostring(screen and screen.phase)) + ok(battle.over, "the battle resolved") + ok(battle.outcome == "win", "and the L60 party won it") + shot("battle-end") + end + for _ = 1, 200 do + if not world.battleActive then break end + tap("a", 3) + end + ok(not world.battleActive, "the battle screen came down") + U.wait(30) + shot("after-battle") + + local vm = world.vm + local state = BattleTower.state(game.save) + say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q") + :format(state.streak, state.challenge, tostring(vm and vm.scriptVar), + tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]), + tostring(vm and vm.stringBuffer))) + ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter") + ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS, + "and armed sBattleTowerChallengeState") + ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought") + ok(state.prevTeams and #state.prevTeams.prev == 3, + "and sBTMonPrevTrainer recorded the team") + + -- The receptionist's heal, the "next up, opponent no. N" prompt and the + -- second draw all follow on their own. + local pages, secondDraw = {}, nil + for _ = 1, 400 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + if #pages <= 6 then shot("page-" .. #pages) end + end + if world.battleActive then + secondDraw = world.vm and world.vm.btOpponent + break + end + if world.choicebox then tap("a", 4) else tap("a", 3) end + end + say("pages: " .. table.concat(pages, " | ")) + local joined = table.concat(pages, " | ") + ok(joined:find("healed to full health", 1, true) ~= nil + or joined:find("Next up", 1, true) ~= nil, + "the room script carried on past the battle") + ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil + or joined:find("no.2", 1, true) ~= nil, + "and wStringBuffer3 named the SECOND opponent") + + if world.battleActive then + U.wait(60) + shot("second-battle") + ok(true, "the loop came back round for opponent two") + say("second opponent: " .. tostring(secondDraw and secondDraw.name)) + end + + say(("streak after round two: %d"):format(BattleTower.state(game.save).streak)) + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_unown_chambers.lua b/tests/drivers/crystal_unown_chambers.lua new file mode 100644 index 00000000..79a61a29 --- /dev/null +++ b/tests/drivers/crystal_unown_chambers.lua @@ -0,0 +1,182 @@ +-- The four Ruins of Alph secret chambers, opened in a real game. +-- +-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love . + +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open; +-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0. +local WALL_BLOCK = 3 +local WALL_SHUT, WALL_OPEN = 0x2e, 0x30 + +local CHAMBERS = { + { + key = "OMANYTE", word = "WATER", + -- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM + shut = function(game) + game.save.inventory = { FIRE_STONE = 1 } + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40, + { item = "FIRE_STONE" }) } + end, + arm = function(game) + game.save.inventory = {} + game.save.party = { + Mon.new(game.data, "TYPHLOSION", 40), + Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }), + } + end, + armed = "a WATER STONE held by the last party mon", + }, + { + key = "HO_OH", word = "HO-OH", + -- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0]. + shut = function(game) + game.save.inventory = {} + game.save.party = { + Mon.new(game.data, "TYPHLOSION", 40), + Mon.new(game.data, "HO_OH", 70), + } + end, + arm = function(game) + game.save.party = { + Mon.new(game.data, "HO_OH", 70), + Mon.new(game.data, "TYPHLOSION", 40), + } + end, + armed = "HO-OH moved into the FIRST party slot", + }, + { + key = "KABUTO", word = "ESCAPE", + shut = function(game) + game.save.inventory = { ESCAPE_ROPE = 1 } + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + end, + -- ../pokecrystal/engine/events/overworld.asm:809 farcall + -- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm. + arm = function(_, world) + UnownWords.kabutoChamber(world.events, world.map and world.map.id) + end, + armed = "SpecialKabutoChamber, as the escape rope calls it", + }, + { + key = "AERODACTYL", word = "LIGHT", + shut = function(game) + game.save.inventory = {} + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + end, + -- ../pokecrystal/engine/events/overworld.asm:285 farcall + -- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash. + arm = function(_, world) + UnownWords.aerodactylChamber(world.events, world.map and world.map.id) + end, + armed = "SpecialAerodactylChamber, as FLASH calls it", + }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function wordScreen() + local state = game.stack:top() + return (state and getmetatable(state) == UnownWords) and state or nil + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "CHRIS" + game.save.player.id = game.save.player.id or 30000 + + local function enter(mapId) + world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down") + U.wait(20) + world.mapScenes[mapId] = 0 + world:setMap(mapId, 3, 1, "up") + for _ = 1, 400 do + U.wait(2) + if not world:busy() and not world.pendingSceneScript then break end + end + U.wait(30) + end + + local function blockNow() + local blocks = world.map and world.map.def and world.map.def.blocks + return blocks and blocks[WALL_BLOCK] + end + + for index, chamber in ipairs(CHAMBERS) do + local mapId = UnownWords.CHAMBER_MAPS[chamber.key] + local flag = UnownWords.WALL_OPENED[chamber.key] + say(("%d %s (flag %d)"):format(index, mapId, flag)) + world.events:set(flag, false) + + chamber.shut(game, world) + enter(mapId) + ok(world.map and world.map.id == mapId, " stood in the chamber") + ok(not world.events:get(flag), " the wall flag is still clear") + ok(blockNow() == WALL_SHUT, + (" and the hidden-doors callback drew the closed wall (%s)") + :format(tostring(blockNow()))) + U.shot(game, ("%s/%d-%s-shut.png"):format(out, index, + chamber.key:lower())) + + chamber.arm(game, world) + say(" armed: " .. chamber.armed) + world.mapScenes[mapId] = 0 + enter(mapId) + ok(world.events:get(flag), " the wall flag is set now") + ok(blockNow() == WALL_OPEN, + (" and the wall-open script rewrote the block (%s)") + :format(tostring(blockNow()))) + U.shot(game, ("%s/%d-%s-open.png"):format(out, index, + chamber.key:lower())) + + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82 + -- RuinsOfAlphOmanyteChamberWallPatternLeft + local screen + for _ = 1, 200 do + screen = wordScreen() + if screen then break end + tap("a", 2) + end + ok(screen ~= nil, " the wall pattern reached DisplayUnownWords") + if screen then + U.wait(20) + ok(screen.wall and screen.wall.word == chamber.word, + (" showing %s (got %s)"):format(chamber.word, + tostring(screen.wall and screen.wall.word))) + U.shot(game, ("%s/%d-%s-word.png"):format(out, index, + chamber.key:lower())) + tap("a", 10) + U.wait(20) + end + end + + say(fails == 0 and "ALL OK" or (fails .. " FAILURES")) + U.wait(10) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_unown_words_shots.lua b/tests/drivers/crystal_unown_words_shots.lua new file mode 100644 index 00000000..7fdf47f5 --- /dev/null +++ b/tests/drivers/crystal_unown_words_shots.lua @@ -0,0 +1,104 @@ +-- The Ruins of Alph wall words and word rooms, driven in a real game. +-- +-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \ +-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love . + + +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP` +local CHAMBERS = { + { map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" }, + { map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" }, + { map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" }, + { map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" }, +} + +local WORD_ROOMS = { + { map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function top() return game.stack:top() end + local function wordScreen() + local state = top() + return (state and getmetatable(state) == UnownWords) and state or nil + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local save = game.save + save.player = save.player or {} + save.player.name = save.player.name or "CHRIS" + save.player.id = save.player.id or 30000 + save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + + for index, chamber in ipairs(CHAMBERS) do + say("A" .. index .. " " .. chamber.map) + world:setMap(chamber.map, 4, 1, "up") + U.wait(30) + U.shot(game, ("%s/a%d-%s-chamber.png"):format( + out, index, chamber.word:lower())) + local screen + for _ = 1, 300 do + screen = wordScreen() + if screen then break end + if not world:busy() then tap("a", 2) else tap("a", 2) end + end + ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords") + if screen then + U.wait(20) + ok(screen.wall and screen.wall.word == chamber.word, + (" showing %s (got %s)"):format(chamber.word, + tostring(screen.wall and screen.wall.word))) + ok(#screen.squares == #chamber.word, + (" %d letter squares"):format(#screen.squares)) + U.shot(game, ("%s/a%d-%s-word.png"):format( + out, index, chamber.word:lower())) + tap("a", 10) + ok(wordScreen() == nil, " A closes the box") + U.wait(20) + end + end + + for index, room in ipairs(WORD_ROOMS) do + say("B" .. index .. " " .. room.map) + world:setMap(room.map, room.x, room.y, "up") + U.wait(40) + ok(world.map and world.map.id == room.map, " stood in " .. room.map) + U.shot(game, ("%s/b%d-%s.png"):format(out, index, + room.map:lower():gsub("ruins_of_alph_", ""))) + end + + say(fails == 0 and "ALL OK" or (fails .. " FAILURES")) + U.wait(10) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_chamber_fieldmoves.lua b/tests/drivers/gate_chamber_fieldmoves.lua new file mode 100644 index 00000000..800f9653 --- /dev/null +++ b/tests/drivers/gate_chamber_fieldmoves.lua @@ -0,0 +1,119 @@ +-- The two chamber walls the FIELD MOVES open, driven through the production +-- call sites rather than by calling the routines directly. +-- +-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash +-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's +-- `.escaperope` arm. +-- +-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love . +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +return function(game) + local fails = 0 + local function say(line) print("[driver] " .. line); io.stdout:flush() end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function clearText() + for _ = 1, 200 do + if not world:busy() then return true end + tap("a", 3) + end + return false + end + + local function enter(mapId) + while game.stack:top() do game.stack:pop() end + assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache") + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + U.wait(10) + end + + -- ---- the escape rope, in the Kabuto chamber ------------------------------ + say("--- KABUTO, through World:useEscapeRope()") + enter(UnownWords.CHAMBER_MAPS.KABUTO) + ok(not UnownWords.wallOpened(world.events, "KABUTO"), + "the Kabuto wall flag starts clear") + game.save.inventory = { ESCAPE_ROPE = 1 } + -- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which + -- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp. + world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 } + clearText() + local rope = world:useEscapeRope("ESCAPE_ROPE") + say("escape rope result: " .. tostring(rope)) + ok(rope == "escape_rope", "the rope was used") + ok(UnownWords.wallOpened(world.events, "KABUTO"), + "and SpecialKabutoChamber set the wall flag") + + -- The rope must not open a wall anywhere else. + world.queuedFieldMove = nil + enter("RUINS_OF_ALPH_OUTSIDE") + clearText() + local before = UnownWords.wallOpened(world.events, "OMANYTE") + game.save.inventory = { ESCAPE_ROPE = 1 } + world:useEscapeRope("ESCAPE_ROPE") + ok(UnownWords.wallOpened(world.events, "OMANYTE") == before, + "a rope used outside a chamber opens nothing") + + -- ---- FLASH, in the Aerodactyl chamber ------------------------------------ + say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")") + enter(UnownWords.CHAMBER_MAPS.AERODACTYL) + ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber") + ok(not UnownWords.wallOpened(world.events, "AERODACTYL"), + "the wall flag starts clear") + + local flash = game.data.moves.FLASH + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40, + { moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) } + + -- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special. + game.save.player = game.save.player or {} + game.save.player.badges = {} + local refused = world:useFieldMove("FLASH", game.save.party[1]) + ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused") + ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map") + ok(not UnownWords.wallOpened(world.events, "AERODACTYL"), + "and the badgeless press did NOT open the wall") + + -- The refusal opened a text box; the world is busy until it is dismissed. + clearText() + game.save.player.badges = { ZEPHYR = true } + local used = world:useFieldMove("FLASH", game.save.party[1]) + say("flash result: ok=" .. tostring(used and used.ok) + .. " action=" .. tostring(used and used.action)) + ok(used and used.ok == true, + "with the badge FLASH is allowed in a chamber that is not a dark cave") + ok(UnownWords.wallOpened(world.events, "AERODACTYL"), + "and SpecialAerodactylChamber set the wall flag") + + enter(UnownWords.CHAMBER_MAPS.AERODACTYL) + U.wait(40) + local _, block = world:blockIndexAt(3, 5) + say("aerodactyl block under the wall: " .. tostring(block)) + U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp") + .. "/01-aerodactyl-flash-open.png") + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_gold_untouched.lua b/tests/drivers/gate_gold_untouched.lua new file mode 100644 index 00000000..e373334d --- /dev/null +++ b/tests/drivers/gate_gold_untouched.lua @@ -0,0 +1,80 @@ +-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's +-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam. +-- +-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \ +-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love . +local U = require("tests.drivers.util") + +local Battle = require("src.battle.gen2.Battle") +local Roamers = require("src.core.gen2.Roamers") + +return function(game) + local fails = 0 + local function say(line) print("[driver] " .. line); io.stdout:flush() end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "gold world did not boot") + say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id)) + + local order = {} + for _, name in ipairs((world.constants or {}).specialOrder or {}) do + order[name] = true + end + ok(next(order) ~= nil, "the Gold cache names its specials") + for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction", + "BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite", + "BuenasPassword", "BuenaPrize", "AskRememberPassword", + "OmanyteChamber", "HoOhChamber", "CelebiShrineEvent", + "SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do + ok(not order[name], "Gold has no " .. name .. " special") + end + + ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map") + ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room") + ok((world.trainers or {}).battleTower == nil, + "trainers.lua carries no battleTower roster") + ok((world.eventTables or {}).unownWalls == nil, + "events.lua carries no unownWalls table") + + -- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only. + for _, flag in ipairs({ 806, 807, 808, 809 }) do + ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear") + end + + -- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs. + for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do + ok(world:readVar(id) == 0, + ("VAR $%02x reads 0 on Gold"):format(id)) + end + + -- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three. + local roster = Roamers.roster(world.encounters) + say("roamer roster: " .. #roster .. " rows") + for _, row in ipairs(roster) do + say((" %s L%s start=%s"):format(tostring(row.species), + tostring(row.level), tostring(row.map))) + end + ok(#roster == 3, "three beasts in the roster") + local list = Roamers.init(game.save, { encounters = world.encounters, force = true }) + local live = 0 + for _, slot in ipairs(list or {}) do + if Roamers.active(slot) then live = live + 1 end + end + ok(live == 3, "all three are active after InitRoamMons") + + -- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only. + ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape") + ok(Battle.noEscapeBattleType({ battleType = 7 }) == true, + "FORCESHINY: no escape") + ok(Battle.noEscapeBattleType({ battleType = 10 }) == false, + "FORCEITEM: Lugia and Ho-Oh can still be run from on Gold") + ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable") + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_tower_noshim.lua b/tests/drivers/gate_tower_noshim.lua new file mode 100644 index 00000000..15994fe3 --- /dev/null +++ b/tests/drivers/gate_tower_noshim.lua @@ -0,0 +1,236 @@ +-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the +-- opponent draw, the walk-in, the battle, and the counter the room script +-- reads back. +-- +-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the +-- battleTower field all come from World itself. +-- +-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower-battle \ +-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love . +local U = require("tests.drivers.util") + +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") + +-- ---- the run -------------------------------------------------------------- + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + ok(type(world.vm.specials.startTowerBattle) == "function", + "World:specialHooks supplies startTowerBattle, unshimmed") + ok(type(world.vm.specials.setObjectSprite) == "function", + "World:specialHooks supplies setObjectSprite, unshimmed") + + local roster = BattleTower.roster(game.data) + ok(roster ~= nil, "trainers.lua carries the Battle Tower roster") + if roster then + say(("roster: %d trainers, %d groups of %d, sample ceiling %d") + :format(#roster.trainers, roster.levelGroups, roster.uniqueMon, + roster.sampleTrainers)) + end + + -- Three legal mons over the L10 room, each with ONE strong move so a blind + -- A-press run cannot pick a status move for a hundred turns. + local party = {} + for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" }, + { "FERALIGATR", 60, "SURF", "BERRY" }, + { "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do + local def = game.data.moves[row[3]] + local mon = Mon.new(game.data, row[1], row[2], { + moves = { { id = row[3], pp = def.pp, maxPp = def.pp } }, + }) + mon.item = row[4] + party[#party + 1] = mon + end + game.save.party = party + + -- The desk's own SAVELEVELGROUP write, so the room the script walks into is + -- the L10 one (battle_tower.asm:1129-1141). + local tower = BattleTower.state(game.save) + tower.levelGroup = 1 + tower.streak = 0 + tower.trainers = {} + tower.challenge = BattleTower.NO_CHALLENGE + + while game.stack:top() do game.stack:pop() end + assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"), + "no BATTLE_TOWER_BATTLE_ROOM in the cache") + U.wait(30) + shot("room-entered") + + -- The scene script walks the player in, draws the opponent and starts the + -- battle; nothing here presses anything until a text box wants it. + local sawOpponent, sawBattle = false, false + for _ = 1, 900 do + local vm = world.vm + if vm and vm.btOpponent and not sawOpponent then + sawOpponent = true + say(("opponent: %s %s (row %d), sprite %s, group %d") + :format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name), + vm.btOpponent.index, tostring(vm.btOpponent.sprite), + vm.btOpponent.group)) + for slot, mon in ipairs(vm.btOpponent.rows) do + say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level, + tostring(mon.item))) + end + U.wait(20) + shot("opponent-walked-in") + end + if world.battleActive then + sawBattle = true + break + end + if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end + end + ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one") + ok(sawBattle, "special BattleTowerBattle pushed the battle screen") + + local screen, battle = nil, nil + if sawBattle then + U.wait(90) + shot("battle-open") + screen = game.stack:top() + battle = screen and screen.battle + ok(battle ~= nil, "the battle screen owns a Battle") + if battle then + ok(battle.inBattleTowerBattle == true, + "wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off") + say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name)) + say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species) + .. " L" .. tostring(battle.enemy and battle.enemy.level)) + ok(battle.trainer ~= nil and #battle.trainer.party == 3, + "with a three-mon Tower party") + end + end + + -- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest + -- move with PP left. + local function hardestMove(mon) + local best, bestPower = 1, -1 + for index, move in ipairs((mon and mon.moves) or {}) do + local def = game.data.moves and game.data.moves[move.id] + local power = (def and def.power) or 0 + if (move.pp or 0) > 0 and power > bestPower then + best, bestPower = index, power + end + end + return best + end + + -- PP topped up between turns: an unattended run otherwise Struggles itself + -- to death long before the third opponent mon is down. + local function refill(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + move.pp = move.maxPp or move.pp + end + end + + local menus = 0 + for _ = 1, 900 do + if not world.battleActive or (battle and battle.over) then break end + local phase = screen and screen.phase + if phase == "menu" then + menus = menus + 1 + if menus == 1 then shot("battle-menu") end + refill(battle and battle.player) + screen.menuIndex = 1 + tap("a", 4) + elseif phase == "moves" then + screen.menuIndex = hardestMove(battle and battle.player) + tap("a", 4) + elseif phase == "submenu" then + -- A forced switch cannot be cancelled, so walk off the fainted lead. + tap("down", 3) + tap("a", 4) + else + tap("a", 3) + end + end + if battle then + say("battle over=" .. tostring(battle.over) + .. " outcome=" .. tostring(battle.outcome) + .. " phase=" .. tostring(screen and screen.phase)) + ok(battle.over, "the battle resolved") + ok(battle.outcome == "win", "and the L60 party won it") + shot("battle-end") + end + for _ = 1, 200 do + if not world.battleActive then break end + tap("a", 3) + end + ok(not world.battleActive, "the battle screen came down") + U.wait(30) + shot("after-battle") + + local vm = world.vm + local state = BattleTower.state(game.save) + say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q") + :format(state.streak, state.challenge, tostring(vm and vm.scriptVar), + tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]), + tostring(vm and vm.stringBuffer))) + ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter") + ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS, + "and armed sBattleTowerChallengeState") + ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought") + ok(state.prevTeams and #state.prevTeams.prev == 3, + "and sBTMonPrevTrainer recorded the team") + + -- The receptionist's heal, the "next up, opponent no. N" prompt and the + -- second draw all follow on their own. + local pages, secondDraw = {}, nil + for _ = 1, 400 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + if #pages <= 6 then shot("page-" .. #pages) end + end + if world.battleActive then + secondDraw = world.vm and world.vm.btOpponent + break + end + if world.choicebox then tap("a", 4) else tap("a", 3) end + end + say("pages: " .. table.concat(pages, " | ")) + local joined = table.concat(pages, " | ") + ok(joined:find("healed to full health", 1, true) ~= nil + or joined:find("Next up", 1, true) ~= nil, + "the room script carried on past the battle") + ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil + or joined:find("no.2", 1, true) ~= nil, + "and wStringBuffer3 named the SECOND opponent") + + if world.battleActive then + U.wait(60) + shot("second-battle") + ok(true, "the loop came back round for opponent two") + say("second opponent: " .. tostring(secondDraw and secondDraw.name)) + end + + say(("streak after round two: %d"):format(BattleTower.state(game.save).streak)) + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/engine/android_shortcuts_payload_test.lua b/tests/engine/android_shortcuts_payload_test.lua index 9af54c09..d7ea27d5 100644 --- a/tests/engine/android_shortcuts_payload_test.lua +++ b/tests/engine/android_shortcuts_payload_test.lua @@ -41,7 +41,7 @@ end -- Mock isReady RomImporter.isReady = function(v) return v == "red" or v == "gold" or v == "blue" or v == "yellow" - or v == "silver" + or v == "silver" or v == "crystal" end local ok = RomImporter.syncAndroidShortcuts("gold") @@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver") check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload") check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first") +capturedShortcuts = nil +RomImporter.syncAndroidShortcuts("crystal") +check(#capturedShortcuts == 4, "nor does a sixth") +check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first") +check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue" + and capturedShortcuts[4] == "yellow", + "and the rest still follow GameVersion.ORDER until the cap") + -- Test with subset of ready games (e.g. only Red and Gold) RomImporter.isReady = function(v) return v == "red" or v == "gold" diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua new file mode 100644 index 00000000..bac6160a --- /dev/null +++ b/tests/engine/crystal_version_test.lua @@ -0,0 +1,218 @@ +-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1 +-- routing, the importer's required-file override and the script dialect. +-- luajit tests/engine/crystal_version_test.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("crystal version registration") +local check = S.check +local eq = S.eq + +local GameVersion = require("src.core.GameVersion") +local Opcodes = require("src.script.gen2.Opcodes") + +-- ------- 1. the VERSIONS row + +local row = GameVersion.VERSIONS.crystal +check(row ~= nil, "GameVersion.VERSIONS carries a crystal row") +eq(row.id, "crystal", "row id") +eq(row.label, "Crystal", "row label") +eq(row.displayName, "Pokemon Crystal", "row display name") +eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1") +eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path") +eq(row.cachePrefix, "crystal/", "cache prefix") +eq(row.saveSuffix, "_crystal", "save suffix") +eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees") +eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees") + +local prefixes, suffixes = {}, {} +for _, id in ipairs(GameVersion.ORDER) do + local info = GameVersion.info(id) + eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique") + eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique") + prefixes[info.cachePrefix] = id + suffixes[info.saveSuffix] = id +end + +-- ------- 2. generation and engine lineage + +eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2") +eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage") +eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage") +eq(GameVersion.engine("silver"), "gs", "and so is Silver") +eq(GameVersion.engine("red"), "gen1", "Red is gen1") +eq(GameVersion.engine("blue"), "gen1", "Blue is gen1") +eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1") + +local LINEAGES = { gen1 = true, gs = true, crystal = true } +for _, id in ipairs(GameVersion.ORDER) do + check(LINEAGES[GameVersion.engine(id)] == true, + id .. " reports a known engine lineage") +end + +eq(GameVersion.generation("gold"), 2, "Gold is Gen 2") +eq(GameVersion.generation("silver"), 2, "Silver is Gen 2") +eq(GameVersion.generation("red"), 1, "Red is Gen 1") + +-- ------- 3. launcher ORDER + +local index +for i, id in ipairs(GameVersion.ORDER) do + if id == "crystal" then index = i end +end +eq(index, 6, "crystal is ORDER slot 6") +eq(#GameVersion.ORDER, 6, "ORDER is six games") +eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4") +eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5") + +-- ------- 4. sha1 routing + +eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal", + "the retail Crystal sha1 resolves to crystal") +eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold", + "Gold's sha1 still resolves to gold") +eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing") + +-- ------- 5. set / get round trip + +local savedCurrent = GameVersion.get() +eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted") +eq(GameVersion.get(), "crystal", "and becomes current") +eq(GameVersion.engine(), "crystal", "engine() with no argument reads current") +check(not GameVersion.isGold(), "isGold() stays false on Crystal") +GameVersion.set(savedCurrent) + +-- ------- 6. the importer's required-file list + +local RomImporter = require("src.import.RomImporter") + +love.filesystem = love.filesystem or {} +local savedGetInfo = love.filesystem.getInfo +local savedGetReal = love.filesystem.getRealDirectory +local savedGetSource = love.filesystem.getSource + +local function probe(version) + local seen, order = {}, {} + love.filesystem.getInfo = function(name) + if not seen[name] then + seen[name] = true + order[#order + 1] = name + end + return { type = "file" } + end + love.filesystem.getRealDirectory = function() return "/nowhere" end + love.filesystem.getSource = function() return "/elsewhere" end + RomImporter.isReady(version) + return seen, order +end + +local crystalSeen, crystalOrder = probe("crystal") +local redSeen = probe("red") + +love.filesystem.getInfo = savedGetInfo +love.filesystem.getRealDirectory = savedGetReal +love.filesystem.getSource = savedGetSource + +check(#crystalOrder > 20, + ("the crystal list is substantial (%d entries probed)"):format(#crystalOrder)) +for _, path in ipairs({ + "crystal/data/generated/encounters.lua", + "crystal/data/generated/landmarks.lua", + "crystal/data/generated/title.lua", + "crystal/data/generated/intro.lua", + "crystal/assets/generated/title/crystal_logo.png", + "crystal/assets/generated/title/crystal_wordmark.png", + "crystal/assets/generated/title/crystal_suicune.png", + "crystal/assets/generated/splash/ditto.png", + "crystal/assets/generated/intro/chris.png", + "crystal/assets/generated/intro/kris.png", + "crystal/assets/generated/battle/front/wooper.png", +}) do + check(crystalSeen[path] == true, "crystal requires " .. path) +end + +for _, path in ipairs({ + "crystal/assets/generated/battle/anims/move_anim_0.png", + "crystal/assets/generated/battle/anims/move_anim_1.png", + "crystal/data/generated/battle_anims.lua", + "crystal/assets/generated/trade/game_boy.png", +}) do + eq(crystalSeen[path], nil, "crystal does not wait on " .. path) +end + +check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true, + "red still requires the Gen 1 battle anim sheet") +eq(redSeen["assets/generated/title/crystal_logo.png"], nil, + "and none of Crystal's title art") + +-- ------- 7. script dialect + +local crystalTable = Opcodes.forEdition("crystal") +local goldTable = Opcodes.forEdition("gold") +check(crystalTable ~= goldTable, + "forEdition('crystal') is not the Gold table") +eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table") +eq(goldTable, Opcodes, "and Gold's table is the module itself") +eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold") + +-- pokecrystal/macros/scripts/events.asm:541 +eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext", + "Crystal $52 is farjumptext") +eq(goldTable[0x52] and goldTable[0x52].name, "jumptext", + "Gold $52 is jumptext") +eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext", + "and Crystal's jumptext moved to $53") + +local function count(tbl) + local n = 0 + for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end + return n +end +eq(count(crystalTable), 170, "Crystal names 170 commands") +eq(count(goldTable), 162, "Gold names 162") + +local diverged +for byte = 0x00, 0x51 do + local a = crystalTable[byte] and crystalTable[byte].name + local b = goldTable[byte] and goldTable[byte].name + if a ~= b then diverged = byte; break end +end +eq(diverged, nil, "$00-$51 are the same commands in both dialects") + +check(Opcodes.TERMINATORS.farjumptext == true, + "farjumptext is a terminator") + +-- ------- 8. the launcher reaches the crystal tab + +local visited = {} +local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter) +fake._switchTab = function(self, id) + self.tab = id + visited[#visited + 1] = id +end + +for _ = 1, 12 do fake:_cycleTab(1) end +local sawCrystal, sawMods, sawBug = false, false, false +for _, id in ipairs(visited) do + if id == "crystal" then sawCrystal = true end + if id == "mods" then sawMods = true end + if id == "bug" then sawBug = true end +end +check(sawCrystal, "cycling the launcher tabs reaches crystal") +check(sawMods and sawBug, "and still reaches the mods and bug tabs") + +local seen, cycle = {}, 0 +fake.tab = "crystal" +repeat + seen[fake.tab] = true + fake:_cycleTab(1) + cycle = cycle + 1 +until fake.tab == "crystal" or cycle > 40 +eq(cycle, #GameVersion.ORDER + 4, + "the ring is the six games plus mods/find/skins/bug") + +fake.tab = "crystal" +fake:_cycleTab(-1) +eq(fake.tab, "silver", "and stepping back off crystal lands on silver") + +S.finish() diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 01558e97..ad632b2c 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1") T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1") T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2") T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2") +T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2") -- ------- 2. manifest: gen2compat is opt-in and defaults off diff --git a/tests/engine/gen2_script_opcodes_test.lua b/tests/engine/gen2_script_opcodes_test.lua new file mode 100644 index 00000000..566d8c2e --- /dev/null +++ b/tests/engine/gen2_script_opcodes_test.lua @@ -0,0 +1,292 @@ +-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal. +-- +-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one +-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with +-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as +-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor +-- emits plausible garbage rather than an error. So the expected tables below +-- are transcribed from the two macro files by hand and pinned here. +-- luajit tests/engine/gen2_script_opcodes_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local Opcodes = require("src.script.gen2.Opcodes") + +-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00. +-- Sizes are the macro body minus its own `db _command`: db 1, dw 2, +-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6). +-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the +-- trainer argument is non-zero (events.asm:352-365) and the table declares the +-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site. +local GOLD_EXPECTED = { + "scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3", + "memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2", + "ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3", + "special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3", + "checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1", + "checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1", + "writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1", + "givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2", + "takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1", + "checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4", + "giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2", + "clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2", + "wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2", + "warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2", + "getitemname 2", "getcurlandmarkname 1", "gettrainername 3", + "getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0", + "reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3", + "writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2", + "closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0", + "promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0", + "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0", + "loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0", + "reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1", + "trainerflagaction 1", "winlosstext 4", "scripttalkafter 0", + "endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1", + "applymovement 3", "applymovementlasttalked 2", "faceplayer 0", + "faceobject 2", "variablesprite 2", "disappear 1", "appear 1", + "follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1", + "loademote 1", "showemote 3", "turnobject 2", "follownotexact 2", + "earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0", + "refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2", + "encountermusic 0", "musicfadeout 3", "playmapmusic 0", + "dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0", + "warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1", + "pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0", + "stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0", + "pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1", + "phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1", + "specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2", + "halloffame 0", "credits 0", "warpfacing 5", +} + +-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules. +-- Cross-checked row for row against ScriptCommandTable +-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the +-- hardware actually indexes and therefore the authority over the macro file. +local CRYSTAL_EXPECTED = { + "scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3", + "memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2", + "ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3", + "special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3", + "checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1", + "checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1", + "writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1", + "givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2", + "takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1", + "checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4", + "giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2", + "clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2", + "wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2", + "warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2", + "getitemname 2", "getcurlandmarkname 1", "gettrainername 3", + "getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0", + "reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3", + "writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2", + "closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2", + "waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0", + "_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0", + "loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0", + "reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1", + "trainerflagaction 1", "winlosstext 4", "scripttalkafter 0", + "endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1", + "applymovement 3", "applymovementlasttalked 2", "faceplayer 0", + "faceobject 2", "variablesprite 2", "disappear 1", "appear 1", + "follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1", + "loademote 1", "showemote 3", "turnobject 2", "follownotexact 2", + "earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0", + "refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2", + "encountermusic 0", "musicfadeout 3", "playmapmusic 0", + "dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0", + "warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1", + "pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0", + "stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0", + "pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1", + "phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1", + "specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", + "verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0", + "warpfacing 5", "battletowertext 1", "getlandmarkname 2", + "gettrainerclassname 2", "getname 3", "wait 1", "checksave 0", +} + +local gold = Opcodes.forEdition("gold") +local crystal = Opcodes.forEdition("crystal") + +-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering. +check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table") +check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])") +check(crystal ~= gold, "crystal resolves to a different table") +check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold") +eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext", + "Opcodes[0x52] is unchanged for existing callers") + +local function auditTable(label, tbl, expected) + local holes, wrong = {}, {} + for i, want in ipairs(expected) do + local byte = i - 1 + local name, size = want:match("^(%S+) (%d+)$") + local row = tbl[byte] + if not row then + holes[#holes + 1] = ("$%02x"):format(byte) + elseif row.name ~= name or row.size ~= tonumber(size) then + wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s") + :format(byte, tostring(row.name), row.size or -1, name, size) + end + end + eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ") + .. ")") + eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ") + .. ")") + local extra = {} + for byte = 0x00, 0xff do + if tbl[byte] and byte >= #expected then + extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name) + end + end + eq(#extra, 0, label .. " declares nothing past the last command (" + .. table.concat(extra, " ") .. ")") +end + +auditTable("gold", gold, GOLD_EXPECTED) +auditTable("crystal", crystal, CRYSTAL_EXPECTED) + +-- (a) $00-$51 is byte-identical between the dialects. +local drift = {} +for byte = 0x00, 0x51 do + local g, c = gold[byte], crystal[byte] + if not (g and c and g.name == c.name and g.size == c.size) then + drift[#drift + 1] = ("$%02x"):format(byte) + end +end +eq(#drift, 0, "$00-$51 is identical in both dialects (" + .. table.concat(drift, " ") .. ")") + +-- and the two dialects agree on NOTHING from $52 up, because the whole tail is +-- shifted by one. farjumptext is the wedge. +eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal") +eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes") +eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53") +eq(crystal[0xa0].size, 3, + "Crystal swarm gained a leading flag byte (events.asm:1003-1008)") +eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id") + +-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count. +local function rowCount(tbl) + local n = 0 + for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end + return n +end +eq(Opcodes.NUM_EVENT_COMMANDS, 162, + "pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2") +eq(crystal.NUM_EVENT_COMMANDS, 170, + "pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa") +eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS, + "the Gold table is dense up to NUM_EVENT_COMMANDS") +eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS, + "the Crystal table is dense up to NUM_EVENT_COMMANDS") + +-- (d) farjumptext ends the walk the same way jumptext does. +check(Opcodes.TERMINATORS.farjumptext, + "farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)") +check(Opcodes.TERMINATORS.jumptext, "jumptext still is") +check(crystal.TERMINATORS == Opcodes.TERMINATORS, + "the Crystal table answers TERMINATORS too") +eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient") + +-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes +-- to it, so ROM data can never be mistaken for a mod verb. +local collide = {} +for byte = 0x00, 0xff do + if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then + collide[#collide + 1] = ("gold $%02x"):format(byte) + end + if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then + collide[#collide + 1] = ("crystal $%02x"):format(byte) + end +end +eq(#collide, 0, "MOD_COMMAND has no byte in either dialect (" + .. table.concat(collide, " ") .. ")") +check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "", + "MOD_COMMAND is a name, not a byte") + +-- The Vm side of the dialect: every Crystal-only verb has a branch, and the +-- shifted `swarm` reads its flag rather than its map group. +love = require("tests.love_stub") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +do + local seen, swarmArgs = {}, nil + local events = Events.new() + local vm = Vm.new({ + generation = 2, + ["s:crystal"] = { + -- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id. + { op = "swarm", args = { 1, 24, 3 } }, + { op = "checksave" }, + { op = "wait", args = { 2 } }, + { op = "getlandmarkname", args = { 5, 3 } }, + { op = "gettrainerclassname", args = { 9, 3 } }, + { op = "getname", args = { 1, 152, 3 } }, + { op = "verbosegiveitemvar", args = { 20, 7 } }, + { op = "farjumptext", text = "t:far" }, + { op = "setevent", event = 1 }, + }, + }, { ["t:far"] = "Far text." }, events, { + setSwarm = function(group, mapNum, kind) + swarmArgs = { group, mapNum, kind } + end, + checkSave = function() return true end, + getLandmarkName = function(id) seen.landmark = id return "RUINS" end, + getTrainerClassName = function(id) seen.class = id return "SAGE" end, + getMonName = function(id) seen.mon = id return "CHIKORITA" end, + readVar = function(id) seen.var = id return 4 end, + giveItem = function(item, qty) seen.give = { item, qty } return true end, + getItemName = function() return "REPEL" end, + showText = function(body, onDone) seen.text = body onDone() end, + }) + + check(vm:start("s:crystal"), "a Crystal-shaped script starts") + for _ = 1, 40 do vm:update() end + check(not vm:running(), "and runs to completion") + + eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]") + eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]") + eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind") + eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook") + eq(seen.class, 9, "gettrainerclassname passes the trainer group") + eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook") + eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var") + eq(seen.give and seen.give[1], 20, "and gives the item the first byte names") + eq(seen.give and seen.give[2], 4, "at the quantity the var held") + eq(seen.text, "Far text.", "farjumptext prints its text") + eq(next(vm.unknownOps or {}), nil, + "no Crystal verb in the script fell through to the unknown ledger") + -- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never + -- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor. + check(not events:get(1), + "farjumptext ended the script before the setevent below it") + events:set(1, true) + check(events:get(1), "and the event really is observable when it is set") +end + +do + -- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not + -- Script_pause's two. + local vm = Vm.new({ generation = 2, + ["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } }, + }, {}, nil, {}) + check(vm:start("s:wait"), "a lone `wait` starts") + local frames = 0 + while vm:running() and frames < 100 do + vm:update() + frames = frames + 1 + end + eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames") +end + +T.finish() diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index a09f6b27..de78bc71 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -39,7 +39,7 @@ end local edited = 0 local hooks = { editTouchControls = function() edited = edited + 1 end } -for _, version in ipairs({ "gold", "silver" }) do +for _, version in ipairs({ "gold", "silver", "crystal" }) do local model = LauncherSettings.open(hooks, version) check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD") check(has(model, "VIBRATION"), version .. " and VIBRATION") @@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do version .. " leaving the flat Gen 1 key alone") eq(model.opts.silver, nil, version .. " and inventing no second Gen 2 block beside it") + eq(model.opts.crystal, nil, version .. " nor a third") local buzz = findRow(model, "VIBRATION") local buzzBefore = buzz.value() @@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false, "no hook, no editor row on Gold") eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false, "nor on Silver") +eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false, + "nor on Crystal") eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false, "nor on Red") -- The Edit row hands the screen to the host, and the host has to know WHICH diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua index 3d410283..76a078bf 100644 --- a/tests/engine/launcher_skins_tab.lua +++ b/tests/engine/launcher_skins_tab.lua @@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil, check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists") check(imp:find("_installMod", 1, true) ~= nil, "and a zip elsewhere still installs a mod") -local cycle = imp:match("local order = %{(.-)%}") -check(cycle and cycle:find('"skins"', 1, true) ~= nil, +local RomImporter = require("src.import.RomImporter") +local GameVersion = require("src.core.GameVersion") +local cycled, probe = {}, nil +probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter }) +probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end +for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end +local reached = " " .. table.concat(cycled, " ") .. " " +check(reached:find(" skins ", 1, true) ~= nil, "shoulder-button tab cycling reaches the skins tab") -check(cycle and cycle:find('"bug"', 1, true) ~= nil, +check(reached:find(" bug ", 1, true) ~= nil, "shoulder-button tab cycling reaches the bug tab") +for _, id in ipairs(GameVersion.ORDER) do + if id ~= GameVersion.ORDER[1] then + check(reached:find(" " .. id .. " ", 1, true) ~= nil, + "shoulder-button tab cycling reaches " .. id) + end +end local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend") check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil, "switching to the tab re-reads the skin list") diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index 87f8fc93..a04c9069 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -27,18 +27,25 @@ end -- ------- tokens expand off GameVersion, never a literal list +-- "nonesuch" is the deliberate never-a-game token for every unknown-version +-- case below. A real version id was used here twice ("gold", then "crystal") +-- and both had to be swapped the day that game shipped; this one never will. +local NO_SUCH_GAME = "nonesuch" + do eq(table.concat(ModTargets.expand("red"), ","), "red", "a version id names exactly that game") eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow", "gen1 is every Gen 1 game, case-insensitive") - eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver", + eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal", "gen2 is every Gen 2 game") eq(table.concat(ModTargets.expand("silver"), ","), "silver", "and each of them names itself") + eq(table.concat(ModTargets.expand("crystal"), ","), "crystal", + "Crystal included, the day its VERSIONS row landed") eq(table.concat(ModTargets.expand("all"), ","), table.concat(GameVersion.ORDER, ","), "all is the launcher order itself") - eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for") + eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for") eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown") eq(ModTargets.expand(7), nil, "a non-string token is not a game") end @@ -48,9 +55,9 @@ do eq(table.concat(versions, ","), "red,gold", "normalize dedupes and sorts into GameVersion.ORDER") eq(#unknown, 0, "known tokens leave nothing unreported") - local _, bad = ModTargets.normalize({ "crystal", "gen1" }) + local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" }) eq(#bad, 1, "an unknown token comes back for the caller to report") - eq(bad[1], "crystal", "by name") + eq(bad[1], NO_SUCH_GAME, "by name") end -- ------- the legacy reading: gen2compat only ever ADDS Gen 2 @@ -58,7 +65,7 @@ end do eq(list(mf({})), "red,blue,yellow", "a manifest with no games key is Gen 1, which is what it was tested as") - eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver", + eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal", "gen2compat keeps Gen 1 and adds Gen 2") eq(mf({}).gen2compat, false, "and the derived flag agrees") eq(mf({ gen2compat = true }).gen2compat, true, "both ways") @@ -69,23 +76,23 @@ end do local gen2 = mf({ games = { "gen2" } }) - eq(list(gen2), "gold,silver", "games can name Gen 2 alone") + eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone") eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads") local both = mf({ games = { "gen1", "gen2" } }) - eq(list(both), "red,blue,yellow,gold,silver", "or both generations") + eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations") local one = mf({ games = { "blue" } }) eq(list(one), "blue", "or one single game") eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim") - eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver", + eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal", "an old gen2compat beside a new games list still adds its game") end do -- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like -- every other manifest vocabulary (Manifest.violation) - local lenient = mf({ games = { "crystal", "red" } }) + local lenient = mf({ games = { NO_SUCH_GAME, "red" } }) eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest") - check(not pcall(mf, { api = 2, games = { "crystal" } }), + check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }), "api 2 refuses a game it does not have") check(not pcall(mf, { games = "gen1" }), "games must be an array, not a bare string") @@ -226,12 +233,13 @@ do local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({ format = "g1rmodlist", formatVersion = 1, profile = { name = "P", enabledByVersion = { - gold = { a = true }, silver = { a = true }, crystal = { a = true }, + gold = { a = true }, silver = { a = true }, + [NO_SUCH_GAME] = { a = true }, red = "nope" } }, })) eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept") eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first") - eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read") + eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read") eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table") end diff --git a/tests/engine/rom_importer_nx_saves_inbox_test.lua b/tests/engine/rom_importer_nx_saves_inbox_test.lua index 5a22437e..4884c8fe 100644 --- a/tests/engine/rom_importer_nx_saves_inbox_test.lua +++ b/tests/engine/rom_importer_nx_saves_inbox_test.lua @@ -57,9 +57,10 @@ end package.loaded["src.core.Platform"] = nil package.loaded["src.import.RomImporter"] = nil RomImporter = require("src.import.RomImporter") +local GameVersion = require("src.core.GameVersion") local function clearSavesInbox() - for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + for _, ver in ipairs(GameVersion.ORDER) do local dir = "imports/saves/" .. ver for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do love.filesystem.remove(dir .. "/" .. name) diff --git a/tests/engine/uwp_baseroms_test.lua b/tests/engine/uwp_baseroms_test.lua index abe735a3..0d74eab7 100644 --- a/tests/engine/uwp_baseroms_test.lua +++ b/tests/engine/uwp_baseroms_test.lua @@ -82,7 +82,7 @@ local function importer(ready) end local allReady = importer({ red = true, blue = true, yellow = true, gold = true, - silver = true }) + silver = true, crystal = true }) allReady:_queueBaseRomScan() eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery") eq(listings, 0, "ready launcher does not enumerate baseroms") @@ -125,7 +125,7 @@ missing:choose("red") eq(picks, 1, "the next import attempt falls back to the native picker") local rescanned = importer({ red = true, blue = true, yellow = true, gold = true, - silver = true }) + silver = true, crystal = true }) rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" } rescanned:reimport("red") check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM") diff --git a/tests/gen2_badges_test.lua b/tests/gen2_badges_test.lua index a782e7d5..9a138c58 100644 --- a/tests/gen2_badges_test.lua +++ b/tests/gen2_badges_test.lua @@ -37,6 +37,31 @@ eq(FieldMoves.BADGE_FLAG[26].store, "badges", "Johto badges go to player.badges" eq(FieldMoves.BADGE_FLAG[34].store, "kantoBadges", "ENGINE_BOULDERBADGE goes to player.kantoBadges") +-- pokecrystal/constants/engine_flags.asm:39 declares 162 flags to pokegold's +-- 93, moving the whole badge block up one. +do + local order = {} + for i = 1, 43 do order[i] = "ENGINE_UNRELATED" .. i end + order[10] = nil -- a const_skip hole must not truncate the map + for index, name in ipairs(FieldMoves.JOHTO_BADGES) do + order[27 + index] = "ENGINE_" .. name .. "BADGE" + end + for index, name in ipairs(FieldMoves.KANTO_BADGES) do + order[35 + index] = "ENGINE_" .. name .. "BADGE" + end + FieldMoves.bindEngineFlags(order) + eq(FieldMoves.BADGE_FLAG[27].name, "ZEPHYR", "crystal ZEPHYRBADGE is 27") + eq(FieldMoves.BADGE_FLAG[28].name, "HIVE", "crystal HIVEBADGE is 28") + eq(FieldMoves.BADGE_FLAG[34].name, "RISING", "crystal RISINGBADGE is 34") + eq(FieldMoves.BADGE_FLAG[35].name, "BOULDER", "crystal BOULDERBADGE is 35") + eq(FieldMoves.BADGE_FLAG[35].store, "kantoBadges", + "a renumbered Kanto badge still lands in kantoBadges") + eq(FieldMoves.BADGE_FLAG[26], nil, "Gold's ZEPHYR slot is vacated") + + FieldMoves.bindEngineFlags(nil) + eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "no map falls back to Gold") +end + -- --------------------------------------------------------------------------- -- The round trip: what a gym script writes is what a field move reads. -- --------------------------------------------------------------------------- diff --git a/tests/gen2_battle_tower_battle_test.lua b/tests/gen2_battle_tower_battle_test.lua new file mode 100644 index 00000000..55b73b60 --- /dev/null +++ b/tests/gen2_battle_tower_battle_test.lua @@ -0,0 +1,603 @@ +-- The Battle Tower BATTLE: the roster tables, the opponent draw and the two +-- specials maps/BattleTowerBattleRoom.asm's loop is built out of. +-- +-- luajit tests/gen2_battle_tower_battle_test.lua +-- +-- ROM-free. The extractor half runs against a synthetic cartridge whose +-- tables are laid out byte for byte the way data/battle_tower/ assembles +-- them, and the rest against a fixture roster taken from +-- ../pokecrystal/data/battle_tower/parties.asm group 1. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle tower battle") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") +local RomExtractorGen2 = require("src.import.RomExtractorGen2") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +-- ---------------------------------------------------------------- fixtures + +-- ../pokecrystal/data/pokemon/base_stats/*.asm, the five species group 1 of +-- BattleTowerMons opens with. +local POKEMON = { + JOLTEON = { name = "JOLTEON", index = 135, types = { "ELECTRIC" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 65, attack = 65, defense = 60, speed = 130, + specialAttack = 110, specialDefense = 95 } }, + ESPEON = { name = "ESPEON", index = 196, types = { "PSYCHIC_TYPE" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 65, attack = 65, defense = 60, speed = 110, + specialAttack = 130, specialDefense = 95 } }, + UMBREON = { name = "UMBREON", index = 197, types = { "DARK" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 95, attack = 65, defense = 110, speed = 65, + specialAttack = 60, specialDefense = 130 } }, + WOBBUFFET = { name = "WOBBUFFET", index = 202, types = { "PSYCHIC_TYPE" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 190, attack = 33, defense = 58, speed = 33, + specialAttack = 33, specialDefense = 58 } }, + KANGASKHAN = { name = "KANGASKHAN", index = 115, types = { "NORMAL" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 105, attack = 95, defense = 80, speed = 90, + specialAttack = 40, specialDefense = 80 } }, +} + +local MOVES = { + THUNDERBOLT = { pp = 15 }, HYPER_BEAM = { pp = 5 }, SHADOW_BALL = { pp = 15 }, + ROAR = { pp = 20 }, MUD_SLAP = { pp = 10 }, PSYCHIC_M = { pp = 10 }, + PSYCH_UP = { pp = 10 }, TOXIC = { pp = 10 }, IRON_TAIL = { pp = 15 }, + COUNTER = { pp = 20 }, MIRROR_COAT = { pp = 20 }, SAFEGUARD = { pp = 25 }, + DESTINY_BOND = { pp = 5 }, REVERSAL = { pp = 15 }, EARTHQUAKE = { pp = 10 }, + ATTRACT = { pp = 15 }, +} + +-- ../pokecrystal/data/battle_tower/parties.asm:4-140, transcribed field for +-- field: the DVs, the stat exp words, the PP bytes and the stats the cart +-- copies straight into wOTPartyMon. +local function row(species, item, moves, pp, dvs, statExp, stats) + return { + species = species, item = item, moves = moves, pp = pp, + level = 10, happiness = 100, experience = 1000, otId = 0, + dvs = { attack = dvs[1], defense = dvs[2], speed = dvs[3], + special = dvs[4] }, + statExp = { hp = statExp[1], attack = statExp[2], defense = statExp[3], + speed = statExp[4], special = statExp[5] }, + hp = stats[1], maxHp = stats[1], + stats = { hp = stats[1], attack = stats[2], defense = stats[3], + speed = stats[4], specialAttack = stats[5], specialDefense = stats[6] }, + } +end + +local GROUP1 = { + row("JOLTEON", "MIRACLEBERRY", + { "THUNDERBOLT", "HYPER_BEAM", "SHADOW_BALL", "ROAR" }, { 15, 5, 15, 20 }, + { 13, 13, 11, 13 }, { 50000, 40000, 40000, 35000, 40000 }, + { 41, 25, 24, 37, 34, 31 }), + row("ESPEON", "LEFTOVERS", + { "MUD_SLAP", "PSYCHIC_M", "PSYCH_UP", "TOXIC" }, { 10, 10, 10, 10 }, + { 14, 13, 15, 11 }, { 40000, 50000, 35000, 40000, 40000 }, + { 39, 26, 24, 35, 38, 31 }), + row("UMBREON", "GOLD_BERRY", + { "SHADOW_BALL", "IRON_TAIL", "PSYCH_UP", "TOXIC" }, { 15, 15, 10, 10 }, + { 13, 11, 14, 15 }, { 40000, 40000, 45000, 50000, 40000 }, + { 46, 25, 34, 26, 25, 39 }), + row("WOBBUFFET", "FOCUS_BAND", + { "COUNTER", "MIRROR_COAT", "SAFEGUARD", "DESTINY_BOND" }, + { 20, 20, 25, 5 }, { 7, 15, 13, 7 }, + { 50000, 50000, 50000, 50000, 50000 }, { 66, 18, 25, 19, 18, 23 }), + -- MIRACLEBERRY again, which is the item collision the draw refuses. + row("KANGASKHAN", "MIRACLEBERRY", + { "REVERSAL", "HYPER_BEAM", "EARTHQUAKE", "ATTRACT" }, { 15, 5, 10, 15 }, + { 14, 15, 12, 15 }, { 40000, 30000, 40000, 30000, 30000 }, + { 47, 31, 29, 29, 20, 28 }), +} + +-- ../pokecrystal/data/battle_tower/classes.asm:11-17 +local ROSTER = { + partyLength = 3, + levelGroups = 2, + uniqueMon = #GROUP1, + uniqueTrainers = 4, + sampleTrainers = 4, + trainers = { + { index = 0, name = "HANSON", class = 37, classId = "FISHER" }, + { index = 1, name = "SAWYER", class = 30, classId = "POKEMANIAC" }, + { index = 2, name = "MASUDA", class = 43, classId = "GUITARIST" }, + { index = 3, name = "NICKEL", class = 20, classId = "SCIENTIST" }, + }, + groups = { GROUP1, GROUP1 }, + classSprites = { + FISHER = "SPRITE_FISHER", POKEMANIAC = "SPRITE_SUPER_NERD", + GUITARIST = "SPRITE_ROCKER", SCIENTIST = "SPRITE_SCIENTIST", + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + trainers = { + battleTower = ROSTER, + classes = { + FISHER = { index = 37, name = "FISHER", baseMoney = 40, + attributes = { 0, 0, 40, 1, 0, 0, 0 }, items = {} }, + POKEMANIAC = { index = 30, name = "POKEMANIAC", baseMoney = 60, + attributes = { 0, 0, 60, 1, 0, 0, 0 }, items = {} }, + GUITARIST = { index = 43, name = "GUITARIST", baseMoney = 36, + attributes = { 0, 0, 36, 1, 0, 0, 0 }, items = {} }, + SCIENTIST = { index = 20, name = "SCIENTIST", baseMoney = 44, + attributes = { 0, 0, 44, 1, 0, 0, 0 }, items = {} }, + }, + }, +} + +local function crystalSave(levelGroup) + local save = Save.normalize({ version = "crystal", generation = 2 }) + save.battleTower.levelGroup = levelGroup or 1 + save.party = {} + return save +end + +-- A roll that walks a canned list, so every draw below is pinned. Same +-- 1..n convention Specials.random uses. +local function rolls(list) + local at = 0 + return function(n) + at = at + 1 + local value = list[at] or 1 + if value > n then value = ((value - 1) % n) + 1 end + return value + end +end + +local function fakeVm(save, hooks) + local vm = { + scriptVar = 0, + mem = {}, + stringBuffer = "", + specials = hooks or {}, + } + vm.specials.save = vm.specials.save or function() return save end + vm.specials.data = vm.specials.data or function() return DATA end + vm.specials.party = vm.specials.party or function() return save.party end + function vm:setStringBuffer(value) self.stringBuffer = value or "" end + function vm:showRaw() end + return vm +end + +-- =========================================== the extractor's roster decode +-- +-- A synthetic cartridge: BattleTowerTrainers, BattleTowerMons and +-- BTTrainerClassSprites laid out exactly as data/battle_tower/classes.asm, +-- parties.asm and data/trainers/sprites.asm assemble, plus the two +-- `maskbits N / cp N / jr nc` pairs load_trainer.asm samples with. +do + local NAME_LENGTH, MON_NAME_LENGTH = 11, 11 + local NICKNAMED = 48 + MON_NAME_LENGTH + local TRAINERS_AT, MONS_AT = 0x4100, 0x4100 + 3 * NAME_LENGTH + local SPRITES_AT, TR_SAMPLE_AT, MON_SAMPLE_AT = 0x5000, 0x5100, 0x5200 + local bytes = {} + local function put(offset, list) + for index, value in ipairs(list) do bytes[offset + index] = value end + end + local function name(text, width) + local out = {} + for index = 1, width do + out[index] = (index <= #text) and text:byte(index) or 0x50 + end + return out + end + -- charmap rows for the letters the three names use. + local charmap, letters = {}, "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for index = 1, #letters do + charmap[tostring(0x80 + index - 1)] = letters:sub(index, index) + end + local function encode(text, width) + local out = {} + for index = 1, width do + if index <= #text then + out[index] = 0x80 + (text:byte(index) - 65) + else + out[index] = 0x50 + end + end + return out + end + -- Three rows, the third exactly NAME_LENGTH - 1 long so the unterminated + -- name the `dname` macro leaves (classes.asm:16 ZABOROWSKI) is covered. + local rows = { { "ABLE", 1 }, { "BAKER", 2 }, { "CDEFGHIJKL", 3 } } + for index, entry in ipairs(rows) do + local at = TRAINERS_AT + (index - 1) * NAME_LENGTH + put(at, encode(entry[1], NAME_LENGTH - 1)) + bytes[at + NAME_LENGTH] = entry[2] + end + -- One level group of two mons. Only the fields the reader names are set; + -- everything else stays 0, which is what ByteFill leaves. + local function monBytes(species, item, moves, pp, dv1, dv2, level, stats) + local raw = {} + for index = 1, NICKNAMED do raw[index] = 0 end + raw[1] = species + raw[2] = item + for slot = 1, 4 do raw[2 + slot] = moves[slot] or 0 end + raw[9], raw[10], raw[11] = 0, 0x03, 0xe8 + raw[12], raw[13] = 0xc3, 0x50 + raw[22], raw[23] = dv1, dv2 + for slot = 1, 4 do raw[23 + slot] = pp[slot] or 0 end + raw[28] = 100 + raw[32] = level + raw[35], raw[36] = 0, stats[1] + raw[37], raw[38] = 0, stats[1] + raw[39], raw[40] = 0, stats[2] + raw[41], raw[42] = 0, stats[3] + raw[43], raw[44] = 0, stats[4] + raw[45], raw[46] = 0, stats[5] + raw[47], raw[48] = 0, stats[6] + return raw + end + put(MONS_AT, monBytes(135, 109, { 85, 63, 247, 46 }, { 15, 5, 15, 20 }, + 0xdd, 0xbd, 10, { 41, 25, 24, 37, 34, 31 })) + put(MONS_AT + NICKNAMED, monBytes(196, 78, { 189, 94, 0, 0 }, { 10, 10, 0, 0 }, + 0xed, 0xfb, 10, { 39, 26, 24, 35, 38, 31 })) + put(SPRITES_AT, { 7, 9, 11 }) + -- `and $03 / cp 3 / jr nc, .resample` and `and $01 / cp 2 / jr nc`. + put(TR_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47, + 0xe6, 0x03, 0xfe, 0x03, 0x30, 0xf3 }) + put(MON_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47, + 0xe6, 0x01, 0xfe, 0x02, 0x30, 0xf3 }) + + local rom = {} + for index = 1, 0x8000 do rom[index] = string.char(bytes[index] or 0) end + local manifest = { + romSha1 = "", + charmap = charmap, + constants = { + trainerClassOrder = { "TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", + "MYSTICALMAN" }, + spriteOrder = { "SPRITE_A", "SPRITE_B", "SPRITE_C", "SPRITE_D", + "SPRITE_E", "SPRITE_F", "SPRITE_G", "SPRITE_H", "SPRITE_I", + "SPRITE_J", "SPRITE_K" }, + moveOrder = {}, itemOrder = {}, speciesOrder = {}, + }, + symbols = { + BattleTowerTrainers = { 1, TRAINERS_AT }, + BattleTowerMons = { 1, MONS_AT }, + BTTrainerClassSprites = { 1, SPRITES_AT }, + ["LoadOpponentTrainerAndPokemon.resample"] = { 1, TR_SAMPLE_AT }, + ["LoadRandomBattleTowerMon.resample"] = { 1, MON_SAMPLE_AT }, + }, + } + manifest.constants.speciesOrder[135] = "JOLTEON" + manifest.constants.speciesOrder[196] = "ESPEON" + manifest.constants.itemOrder[109] = "MIRACLEBERRY" + manifest.constants.itemOrder[78] = "LEFTOVERS" + manifest.constants.moveOrder[85] = "THUNDERBOLT" + manifest.constants.moveOrder[63] = "HYPER_BEAM" + manifest.constants.moveOrder[247] = "SHADOW_BALL" + manifest.constants.moveOrder[46] = "ROAR" + manifest.constants.moveOrder[189] = "MUD_SLAP" + manifest.constants.moveOrder[94] = "PSYCHIC_M" + + local ex = RomExtractorGen2.new(table.concat(rom), manifest, nil) + local read = ex:readBattleTowerRoster(manifest.constants, charmap) + check(read ~= nil, "the roster reads out of the cartridge") + eq(read.uniqueTrainers, 3, "the trainer count is the gap to BattleTowerMons") + eq(read.uniqueMon, 2, "NUM_UNIQUE_MON comes off the mon resample loop") + eq(read.sampleTrainers, 3, + "and the trainer ceiling off its own, which Crystal 1.0 gets wrong") + eq(read.trainers[1].name, "ABLE", "a terminated name stops at '@'") + eq(read.trainers[3].name, "CDEFGHIJKL", + "and a full-width one runs to NAME_LENGTH - 1 with no terminator") + eq(read.trainers[2].classId, "WHITNEY", "the class byte resolves to a name") + eq(read.classSprites.FALKNER, "SPRITE_G", "class 1 takes the first sprite") + eq(read.classSprites.BUGSY, "SPRITE_K", "class 3 the third") + eq(read.classSprites.MYSTICALMAN, nil, + "and MYSTICALMAN is off the end of BTTrainerClassSprites") + eq(#read.groups, 10, "ten level groups are always read") + local first = read.groups[1][1] + eq(first.species, "JOLTEON", "group 1 mon 1 species") + eq(first.item, "MIRACLEBERRY", "its held item") + eq(first.level, 10, "its level") + eq(first.happiness, 100, "its happiness") + eq(first.experience, 1000, "its three exp bytes, big-endian") + eq(first.statExp.hp, 50000, "its HP stat exp word") + eq(first.dvs.attack, 13, "attack DV out of the high nibble") + eq(first.dvs.defense, 13, "defense DV out of the low nibble") + eq(first.dvs.speed, 11, "speed DV") + eq(first.dvs.special, 13, "special DV") + eq(#first.moves, 4, "four moves") + eq(first.moves[3], "SHADOW_BALL", "move three") + eq(first.pp[2], 5, "and its PP byte") + eq(first.maxHp, 41, "the stored max HP") + eq(first.stats.speed, 37, "and the stored Speed") + local second = read.groups[1][2] + eq(#second.moves, 2, "a row with two NO_MOVE slots keeps two moves") + eq(#second.pp, 2, "and two PP bytes") +end + +-- =========================== Mon.stats agrees with the cart's stored stats +-- +-- ../pokecrystal/engine/pokemon/move_mon.asm CalcMonStats is what produced the +-- bytes in parties.asm, so the port's own formula has to land on them. +do + for _, mon in ipairs(GROUP1) do + local dvs = { attack = mon.dvs.attack, defense = mon.dvs.defense, + speed = mon.dvs.speed, special = mon.dvs.special } + local stats = Mon.stats(POKEMON[mon.species].baseStats, dvs, mon.level, + mon.statExp) + eq(stats.hp, mon.stats.hp, mon.species .. " HP matches the ROM") + eq(stats.attack, mon.stats.attack, mon.species .. " Attack matches") + eq(stats.defense, mon.stats.defense, mon.species .. " Defense matches") + eq(stats.speed, mon.stats.speed, mon.species .. " Speed matches") + eq(stats.specialAttack, mon.stats.specialAttack, + mon.species .. " Sp.Atk matches") + eq(stats.specialDefense, mon.stats.specialDefense, + mon.species .. " Sp.Def matches") + end +end + +-- ================================================ roster lookup and gating +do + eq(BattleTower.roster(DATA), ROSTER, "the roster comes off trainers.lua") + eq(BattleTower.roster({ trainers = {} }), nil, + "a Gold cache has no battleTower block") + eq(BattleTower.roster({ trainers = { battleTower = { trainers = {} } } }), nil, + "and half a block is not one either") + + local save = crystalSave(0) + eq(BattleTower.opponentGroup(save, ROSTER), 1, + "a level group of 0 cannot index before the table") + save.battleTower.levelGroup = 9 + eq(BattleTower.opponentGroup(save, ROSTER), 2, + "nor past the last group the roster carries") + save.battleTower.levelGroup = 2 + eq(BattleTower.opponentGroup(save, ROSTER), 2, "a real group is kept") +end + +-- ============================================== the trainer draw and sBTTrainers +do + local save = crystalSave() + local trainer = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 })) + eq(trainer.name, "SAWYER", "the second row is drawn") + eq(save.battleTower.trainers[1], 1, + "and recorded in sBTTrainers[sNrOfBeatenBattleTowerTrainers]") + + -- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:44-51: the + -- streak list is what the reroll refuses. + save.battleTower.streak = 1 + local again = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 })) + check(again.name ~= "SAWYER", "a trainer already in the streak is refused") + eq(save.battleTower.trainers[2], again.index, + "and the new one lands in the next slot") + + -- :29-37, Crystal 1.0's ceiling: with sampleTrainers 2 only the first two + -- rows can ever come out, however the roll lands. + local capped = { partyLength = 3, levelGroups = 1, uniqueMon = #GROUP1, + uniqueTrainers = 4, sampleTrainers = 2, trainers = ROSTER.trainers, + groups = { GROUP1 }, classSprites = ROSTER.classSprites } + local seen = {} + for roll = 1, 8 do + local fresh = crystalSave() + seen[BattleTower.chooseTrainer(fresh, capped, rolls({ roll })).index] = true + end + eq(seen[2], nil, "row 2 is past the 1.0 ceiling and never drawn") + eq(seen[3], nil, "nor row 3") + check(seen[0] and seen[1], "the first two rows both are") +end + +-- ================================================== the three-mon team draw +do + local save = crystalSave() + local team = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 })) + eq(#team, 3, "three mons come out") + eq(team[1].species, "JOLTEON", "the first roll takes the first row") + -- :131-136, the species and item collisions: JOLTEON is out, and so is + -- KANGASKHAN, which holds JOLTEON's MIRACLEBERRY. + eq(team[2].species, "ESPEON", + "the second roll skips the species already picked") + eq(team[3].species, "UMBREON", "and so does the third") + local held = {} + for _, mon in ipairs(team) do + eq(held[mon.item], nil, mon.species .. " brings an unheld item") + held[mon.item] = true + end + + -- :149-166 and :195-206: the last two teams' species are refused too. + eq(save.battleTower.prevTeams.prev[1], "JOLTEON", + "the drawn team becomes sBTMonPrevTrainer") + eq(#save.battleTower.prevTeams.prevPrev, 0, + "with nothing displaced into sBTMonPrevPrevTrainer yet") + local second = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 })) + eq(second[1].species, "WOBBUFFET", "the next team skips the previous one") + eq(second[2].species, "KANGASKHAN", "and keeps skipping it") + eq(save.battleTower.prevTeams.prevPrev[1], "JOLTEON", + "and the old team shifts down to sBTMonPrevPrevTrainer") + + -- Five rows cannot field three fresh species twice running, which is + -- exactly where the cart's `jr z, .FindARandomBattleTowerMon` would spin + -- forever: the port takes the unfiltered draw instead and still hands back + -- a full team. Twenty-one rows never reach it. + eq(#second, 3, "an exhausted pool still fields three rather than hanging") + eq(second[3].species, "JOLTEON", "the third falls back to a plain roll") +end + +-- ============================================== the party the battle fights +do + local party = BattleTower.battleParty(DATA, { GROUP1[1], GROUP1[3] }) + eq(#party, 2, "one battle mon per roster row") + local jolteon = party[1] + eq(jolteon.species, "JOLTEON", "species") + eq(jolteon.name, "JOLTEON", "and the species name, not the ROM nickname") + eq(jolteon.nickname, nil, "which is why no nickname is carried") + eq(jolteon.level, 10, "level") + eq(jolteon.item, "MIRACLEBERRY", "held item") + eq(jolteon.happiness, 100, "happiness") + eq(jolteon.maxHp, 41, "max HP off the stored bytes") + eq(jolteon.hp, 41, "at full") + eq(jolteon.stats.speed, 37, "stored Speed") + eq(jolteon.stats.specialDefense, 31, "stored Sp.Def") + eq(jolteon.dvs.attack, 13, "the row's own DVs, not a roll") + eq(#jolteon.moves, 4, "four moves") + eq(jolteon.moves[2].id, "HYPER_BEAM", "move two") + eq(jolteon.moves[2].pp, 5, "with the row's PP") + eq(jolteon.moves[2].maxPp, 5, "and the move's own maximum") + eq(party[2].species, "UMBREON", "and the second row follows") + -- Mon.new mutates the dvs table it is handed, so a second build has to see + -- the roster row untouched. + local again = BattleTower.battleParty(DATA, { GROUP1[1] }) + eq(again[1].stats.attack, 25, "a second build reads the same row") + eq(GROUP1[1].dvs.hp, nil, "and leaves the roster row alone") +end + +-- ============================== LoadOpponentTrainerAndPokemonWithOTSprite +do + local painted = {} + local save = crystalSave() + local vm = fakeVm(save, { + setObjectSprite = function(object, sprite) + painted[#painted + 1] = { object = object, sprite = sprite } + end, + }) + vm.scriptVar = 2 + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm) + eq(vm.scriptVar, 2, "wScriptVar still names the object, unwritten") + check(vm.btOpponent ~= nil, "the opponent is drawn onto the VM") + eq(vm.btOpponent.name, "HANSON", "the trainer's own name") + eq(vm.btOpponent.classId, "FISHER", "its class") + eq(#vm.btOpponent.rows, 3, "with three mons") + eq(#painted, 1, "the map object is repainted once") + eq(painted[1].object, 2, "the object wScriptVar named") + eq(painted[1].sprite, "SPRITE_FISHER", + "with BTTrainerClassSprites[class - 1]") + eq(save.battleTower.streak, 0, "the draw does not step the streak") + + -- A save is all it needs; a world with no sprite hook still draws. + local bare = crystalSave() + local plain = fakeVm(bare, {}) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(plain) + check(plain.btOpponent ~= nil, "and the draw survives a missing hook") + + -- A cache with no roster leaves nothing behind rather than half an opponent. + local goldVm = fakeVm(crystalSave(), { data = function() return {} end }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(goldVm) + eq(goldVm.btOpponent, nil, "a cache with no roster draws nobody") +end + +-- ============================================================ BattleTowerBattle +local function towerBattle(save, outcome, extra) + local log = { heals = 0, started = nil } + local hooks = { + healParty = function() log.heals = log.heals + 1 end, + startTowerBattle = function(spec, done) + log.started = spec + log.healsAtStart = log.heals + done(outcome) + return true + end, + } + for key, value in pairs(extra or {}) do hooks[key] = value end + local vm = fakeVm(save, hooks) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm) + Specials.ALL.BattleTowerBattle(vm) + return vm, log +end + +do + local save = crystalSave() + local vm, log = towerBattle(save, "win") + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:236-237 + eq(vm.scriptVar, 0, "a win leaves wBattleResult 0 in wScriptVar") + eq(log.heals, 2, ":228 and :235, HealParty on both sides of the battle") + eq(log.healsAtStart, 1, "the first one before the send-out") + check(log.started ~= nil, "the battle was started") + eq(log.started.name, "FISHER HANSON", "named class-then-trainer") + eq(log.started.trainerName, "HANSON", "with the bare name kept") + eq(log.started.classId, "FISHER", "and the class key the pic reads") + eq(log.started.class, 37, "and its constant") + eq(log.started.baseMoney, 40, "the class's own payout multiplier") + eq(#log.started.party, 3, "three mons walk in") + eq(log.started.party[1].species, "JOLTEON", "the drawn team, built") + eq(log.started.party[1].maxHp, 41, "with the roster's stats") + + -- :549-570 CopyBTTrainer, which is what steps the counter. + eq(save.battleTower.streak, 1, "the streak steps once") + eq(save.battleTower.challenge, BattleTower.CHALLENGE_IN_PROGRESS, + "and the challenge is now in progress") + -- :240-250, the byte maps/BattleTowerBattleRoom.asm:38 reads back. + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 1, + "wNrOfBeatenBattleTowerTrainers holds the new count") + eq(vm.stringBuffer, "2", "and wStringBuffer3 the NEXT opponent's number") + eq(vm.btBattleEnded, 1, "wBattleTowerBattleEnded ends the loop") + eq(vm.btOpponent, nil, "and the drawn opponent is spent") +end + +do + local save = crystalSave() + save.battleTower.streak = 6 + local vm = towerBattle(save, "win") + eq(save.battleTower.streak, 7, "the seventh win reaches BATTLETOWER_STREAK_LENGTH") + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 7, + "which is what the room script's `ifequal 7` reads") +end + +do + local save = crystalSave() + local vm, log = towerBattle(save, "lose") + eq(vm.scriptVar, 1, "a loss leaves wBattleResult 1") + eq(log.heals, 2, "and still heals on both sides") + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], nil, + ":238-239 skips the counter copy when the battle was lost") + eq(vm.stringBuffer, "", "and prints no next-opponent number") + eq(save.battleTower.streak, 1, + "the counter still stepped, because ReadBTTrainerParty ran") +end + +do + -- No hook at all: the challenge has to END rather than loop on an opponent + -- that never appears. + local save = crystalSave() + local vm = fakeVm(save, {}) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.BattleTowerBattle(vm) + eq(vm.scriptVar, 1, "an unwired world reports a loss") + eq(vm.btBattleEnded, 1, "and ends the tower loop") + + local goldVm = fakeVm(crystalSave(), { data = function() return {} end }) + Specials.ALL.BattleTowerBattle(goldVm) + eq(goldVm.scriptVar, 1, "so does a cache with no roster") +end + +do + -- The special draws its own opponent when the room script never loaded one. + local save = crystalSave() + local log + local vm = fakeVm(save, { + healParty = function() end, + startTowerBattle = function(spec, done) log = spec; done("win"); return true end, + }) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.BattleTowerBattle(vm) + check(log ~= nil and #log.party == 3, + "BattleTowerBattle alone still fields an opponent") +end + +-- ============================================ InitBattleTowerChallengeRAM +do + local save = crystalSave() + local vm = fakeVm(save, { pushScreen = function() return false end }) + vm.mem[BattleTower.WRAM_NR_BEATEN] = 4 + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 0, + ":193 zeroes wNrOfBeatenBattleTowerTrainers with the rest") + eq(vm.btBattleEnded, 0, "and wBattleTowerBattleEnded with it") +end + +Specials.random = math.random + +S.finish() diff --git a/tests/gen2_battle_tower_test.lua b/tests/gen2_battle_tower_test.lua new file mode 100644 index 00000000..a4c7685c --- /dev/null +++ b/tests/gen2_battle_tower_test.lua @@ -0,0 +1,629 @@ +-- The Battle Tower: engine/events/battle_tower/battle_tower.asm, +-- engine/events/battle_tower/rules.asm and the room menu at +-- mobile/mobile_46.asm:137. +-- +-- luajit tests/gen2_battle_tower_test.lua +-- +-- ROM-free. The lobby rules, the challenge-state machine, the level/uber +-- room gates, the reward roll and the wInBattleTowerBattle badge guard, all +-- against fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle tower") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleTower = require("src.core.gen2.BattleTower") +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local Specials = require("src.script.gen2.Specials") + +local A = BattleTower.ACTIONS + +-- ---------------------------------------------------------------- fixtures + +local ITEM_ORDER = {} +do + -- ../pokecrystal/constants/item_constants.asm: only the span the reward roll + -- walks has to be at its real index, so the filler carries the rest. + for i = 1, 250 do ITEM_ORDER[i] = "FILLER_" .. i end + ITEM_ORDER[18] = "POTION" + ITEM_ORDER[26] = "HP_UP" + ITEM_ORDER[27] = "PROTEIN" + ITEM_ORDER[28] = "IRON" + ITEM_ORDER[29] = "CARBOS" + ITEM_ORDER[30] = "LUCKY_PUNCH" + ITEM_ORDER[31] = "CALCIUM" +end + +local DATA = { + gen2Constants = { itemOrder = ITEM_ORDER }, + items = { + POTION = { index = 18, pocket = "ITEM" }, + HP_UP = { index = 26, pocket = "ITEM" }, + CALCIUM = { index = 31, pocket = "ITEM" }, + }, +} + +local function mon(species, level, item, isEgg) + return { species = species, level = level, item = item, isEgg = isEgg } +end + +local function crystalSave() + local save = { version = "crystal", player = { id = 7, badges = {} }, + party = {}, inventory = {} } + return save +end + +-- The hooks World:specialHooks hands a handler, stubbed. +local function fakeVm(opts) + opts = opts or {} + local vm = { + scriptVar = opts.scriptVar or 0, + specials = opts.hooks or {}, + pages = {}, + stringBuffer = "", + } + function vm:setStringBuffer(value) self.stringBuffer = value or "" end + function vm:showRaw(body) + body = tostring(body) + if self.stringBuffer ~= "" then + body = body:gsub("{STRBUF}", self.stringBuffer) + end + self.pages[#self.pages + 1] = body + end + return vm +end + +local function towerVm(save, extra) + local hooks = { + save = function() return save end, + data = function() return DATA end, + party = function() return save.party end, + itemIndex = function(id) + for index, name in ipairs(ITEM_ORDER) do + if name == id then return index end + end + return 0 + end, + } + for key, value in pairs(extra or {}) do hooks[key] = value end + return fakeVm({ hooks = hooks }) +end + +local function action(vm, id) + vm.scriptVar = id + Specials.ALL.BattleTowerAction(vm) + return vm.scriptVar +end + +-- ============================================== the seam and the ownership +do + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-259 and + -- :1534-1576 joined the list once the roster reached trainers.lua; their own + -- behaviour is tests/gen2_battle_tower_battle_test.lua. + for _, name in ipairs({ "BattleTowerAction", "CheckForBattleTowerRules", + "Menu_ChallengeExplanationCancel", "BattleTowerRoomMenu", + "BattleTowerBattle", "LoadOpponentTrainerAndPokemonWithOTSprite" }) do + eq(Specials.HANDLER_SOURCE[name], "specials/battle_tower.lua", + name .. " is owned by specials/battle_tower.lua") + check(type(Specials.ALL[name]) == "function", + name .. " resolves to a function") + eq(Specials.STUBS[name], nil, name .. " is no longer a stub") + end + + for _, name in ipairs({ "BattleTowerMobileError" }) do + check(type(Specials.STUB_REASONS[name]) == "string", + name .. " is still a deliberate stub, with its reason") + end + + local found = false + for _, id in ipairs(Screens.GEN2_IDS) do + if id == "Gen2BattleTowerMenu" then found = true end + end + check(found, "Gen2BattleTowerMenu joined Screens.GEN2_IDS") +end + +-- ================================================= _CheckForBattleTowerRules +do + local legal = { mon("PIKACHU", 40, "BERRY"), mon("GEODUDE", 40, "GOLD_BERRY"), + mon("HORSEA", 40) } + local lines, failed = BattleTower.checkRules(legal) + eq(#lines, 0, "a legal three-mon party prints nothing") + eq(failed, false, "and _CheckForBattleTowerRules returns no carry") + + -- Two itemless mons do not collide: `ld a, [hl] / and a / jr z, .next` + -- drops a zero value before the inner scan ever runs. + local bare = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + eq(#(BattleTower.checkRules(bare)), 0, + "three mons holding nothing is legal") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|" + .. "_BattleTowerReturnWhenReadyText", + "two mons: the header, the count line and the tail") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("PIKACHU", 40), + mon("HORSEA", 40) }) + eq(lines[2], "_TheMonMustAllBeDifferentKindsText", + "a duplicate species is the second check") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("GEODUDE", 40, "BERRY"), mon("HORSEA", 40) }) + eq(lines[2], "_TheMonMustNotHoldTheSameItemsText", + "a duplicate held item is the third") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40), + mon("ODD_EGG", 5, nil, true) }) + eq(lines[2], "_YouCantTakeAnEggText", "an egg is the fourth") + + -- An egg is skipped by BOTH uniqueness walks (CheckPartyValueIsUnique's + -- `.isegg` guards each side), so it only trips its own check. + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("PIKACHU", 40, "BERRY", true), mon("HORSEA", 40) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_YouCantTakeAnEggText|" + .. "_BattleTowerReturnWhenReadyText", + "an egg duplicating a species and an item still only fails the egg rule") + + -- Every check runs; BattleTower_ExecuteJumptable does not stop at the first. + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("PIKACHU", 40, "BERRY"), mon("HORSEA", 40, "BERRY"), + mon("ODD_EGG", 5, nil, true) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|" + .. "_TheMonMustAllBeDifferentKindsText|" + .. "_TheMonMustNotHoldTheSameItemsText|_YouCantTakeAnEggText|" + .. "_BattleTowerReturnWhenReadyText", + "four failures print the header, all four lines and the tail, in order") +end + +-- ---- the special itself +do + local save = crystalSave() + save.party = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + local vm = towerVm(save) + Specials.ALL.CheckForBattleTowerRules(vm) + eq(vm.scriptVar, 0, "a legal party answers FALSE, the `ifnotequal FALSE` arm") + eq(#vm.pages, 0, "and prints nothing") + + save.party = { mon("PIKACHU", 40) } + vm = towerVm(save) + Specials.ALL.CheckForBattleTowerRules(vm) + eq(vm.scriptVar, 1, "a broken party answers TRUE") + eq(#vm.pages, 3, "and prints header, refusal and tail") + eq(vm.stringBuffer, "3", "wStringBuffer2 holds the '3' the lines splice in") +end + +-- ============================================== the rooms and their gates +do + local save = crystalSave() + eq(#BattleTower.levelGroupRows(save), 4, + "before the Hall of Fame only L10-L40 are offered") + save.hallOfFame = { count = 1, teams = {} } + eq(#BattleTower.levelGroupRows(save), 10, + "and all ten once STATUSFLAGS_HALL_OF_FAME_F is set") + + -- maps/BattleTowerHallway.asm:40-47, the room the receptionist walks to. + eq(BattleTower.roomOf(1), 0, "L10 is the 10/20 room") + eq(BattleTower.roomOf(2), 0, "L20 shares it") + eq(BattleTower.roomOf(3), 1, "L30 is the 30/40 room") + eq(BattleTower.roomOf(4), 1, "L40 shares it") + eq(BattleTower.roomOf(9), 4, "L90 is the 90/100 room") + eq(BattleTower.roomOf(10), 4, "L100 shares it") + + local party = { mon("PIKACHU", 30), mon("GEODUDE", 30), mon("HORSEA", 30) } + eq(BattleTower.levelCheck(party, 3), false, "L30 mons fit the L30 room") + eq(BattleTower.levelCheck(party, 2), true, "and top the L20 room") + party[1].level = 31 + eq(BattleTower.levelCheck(party, 3), true, "one mon over is enough") + + local ubers = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + eq(BattleTower.ubersCheck(ubers, 4), "LUGIA", + "an uber is refused below the L70 rooms") + eq(BattleTower.ubersCheck(ubers, 7), nil, + "and allowed from the L70 room up") + ubers[1] = mon("MEWTWO", 70) + eq(BattleTower.ubersCheck(ubers, 6), nil, + "an uber already at L70 is past the `cp 70 / jr c` gate") + eq(BattleTower.ubersCheck({ mon("DRAGONITE", 40) }, 4), nil, + "and DRAGONITE is not on the list") +end + +-- ==================================================== the reward (:906-976) +do + -- The `maskbits` roll is over eight values folded into six, so HP_UP and + -- PROTEIN come up twice as often; LUCKY_PUNCH is rerolled. + local seen = {} + for roll = 1, 8 do + local n = 0 + local item = BattleTower.rollReward(ITEM_ORDER, function(mask) + n = n + 1 + -- Specials.random answers 1..mask, and the handler folds it with % mask. + return (n == 1) and roll or 1 + end) + seen[roll % 8] = item + end + eq(seen[1], "PROTEIN", "roll 1 is PROTEIN") + eq(seen[2], "IRON", "roll 2 is IRON") + eq(seen[3], "CARBOS", "roll 3 is CARBOS") + eq(seen[5], "CALCIUM", "roll 5 is CALCIUM") + eq(seen[0], "HP_UP", "roll 0 is HP_UP") + eq(seen[6], "HP_UP", "roll 6 folds back onto HP_UP") + eq(seen[7], "PROTEIN", "roll 7 folds back onto PROTEIN") + check(seen[4] ~= "LUCKY_PUNCH", "roll 4 lands on LUCKY_PUNCH and rerolls") + + eq(BattleTower.rewardFits(19, 20, nil), true, + "a pocket with a free slot takes the five") + eq(BattleTower.rewardFits(20, 20, nil), false, + "a full pocket holding none of the reward cannot") + eq(BattleTower.rewardFits(20, 20, 94), true, + "a full pocket already holding 94 can stack five more") + eq(BattleTower.rewardFits(20, 20, 95), false, + "95 is where MAX_ITEM_STACK - 5 + 1 stops it") +end + +-- ================================== BattleTowerAction, every jumptable row +do + local save = crystalSave() + local vm = towerVm(save) + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:855-887 + local unhandled = {} + for id = 0, BattleTower.NUM_ACTIONS - 1 do + vm.scriptVar = id + local before = vm.scriptVar + local ok = pcall(Specials.ALL.BattleTowerAction, vm) + if not ok then unhandled[#unhandled + 1] = id end + if ok and vm.scriptVar == before and before ~= 0 then + -- a row that answers nothing must be one of the documented no-writes + local writesNothing = (id == A.SET_EXPLANATION_READ) + or (id == A.SAVE_AND_QUIT) or (id == A.CHALLENGECANCELED) + or (id == A.ACTION_06) or (id == A.SAVELEVELGROUP) + or (id == A.LOADLEVELGROUP) or (id == A.ACTION_0A) + or (id == A.ACTION_0C) or (id == A.ACTION_11) or (id == A.ACTION_12) + or (id == A.ACTION_15) or (id == A.ACTION_16) or (id == A.RESETDATA) + or (id == A.ACTION_1C) or (id == A.ACTION_1D) + or (id == A.CHOOSEREWARD) or (id == A.SAVEOPTIONS) + if not writesNothing then unhandled[#unhandled + 1] = id end + end + end + eq(table.concat(unhandled, ","), "", + "all 32 BattleTowerAction rows run, and only the cart's silent ones " + .. "leave wScriptVar alone") +end + +-- ---- the challenge state machine (:992-1022, :935-949) +do + local save = crystalSave() + local vm = towerVm(save) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE, + "a fresh save is BATTLETOWER_NO_CHALLENGE") + action(vm, A.SAVE_AND_QUIT) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.SAVED_AND_LEFT, + "the quicksave arm parks on BATTLETOWER_SAVED_AND_LEFT") + action(vm, A.ACTION_1C) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.WON_CHALLENGE, + "beating the seventh trainer is BATTLETOWER_WON_CHALLENGE") + action(vm, A.ACTION_1D) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.RECEIVED_REWARD, + "and taking the prize is BATTLETOWER_RECEIVED_REWARD") + action(vm, A.CHALLENGECANCELED) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE, + "cancelling clears it") +end + +-- ---- sBattleTowerSaveFileFlags (:978-1008, :1471-1492) +do + local save = crystalSave() + local tower = BattleTower.state(save) + eq(tower.saveFileFlags, 0, "a fresh save has no tower flags") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION) + eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION), 2, + "`and 2` answers the masked byte, not a boolean") + eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_REGISTERED), 0, + "and bit 0 is untouched") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION) + eq(tower.saveFileFlags, 2, "`or 2` twice is still 2") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED) + eq(tower.saveFileFlags, 3, "both bits live in the one byte") + + -- :979-982 -- with no save file on disk the routine returns FALSE without + -- ever reading the flags. + local vm = towerVm(save) + eq(action(vm, A.CHECK_EXPLANATION_READ), 0, + "no save file means the explanation check answers FALSE") + eq(action(vm, A.CHECKSAVEFILEISYOURS), 0, + "and so does the save-file check itself") +end + +-- ---- the level group (:1129-1155) and the GS Ball (:1179-1185) +do + local save = crystalSave() + local vm = towerVm(save) + vm.btLevelGroup = 7 + action(vm, A.SAVELEVELGROUP) + eq(BattleTower.state(save).levelGroup, 7, + "SAVELEVELGROUP banks wBTChoiceOfLvlGroup in SRAM") + vm.btLevelGroup = nil + action(vm, A.LOADLEVELGROUP) + eq(vm.btLevelGroup, 7, "and LOADLEVELGROUP brings it back for the hallway") + + eq(action(vm, A.GSBALL), 0, "no GS Ball, no scene") + Save.crystalState(save).gsBall = "have" + eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE, + "sGSBallFlag reads back as GS_BALL_AVAILABLE ($b)") + Save.crystalState(save).gsBall = "given" + eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE, + "and stays set once the ball has been handed over") +end + +-- ---- RESETDATA and the prize (:890-933, :955-976) +do + local save = crystalSave() + local tower = BattleTower.state(save) + tower.streak = 5 + tower.trainers = { 3, 9 } + local vm = towerVm(save) + action(vm, A.RESETDATA) + eq(tower.streak, 0, "RESETDATA clears sNrOfBeatenBattleTowerTrainers") + eq(next(tower.trainers), nil, "and the seven sBTTrainers slots") + + local rewards = {} + for _ = 1, 200 do + action(vm, A.CHOOSEREWARD) + rewards[tower.reward] = true + end + eq(rewards.POTION, nil, + "CHOOSEREWARD never falls back on POTION: it reads gen2Constants.itemOrder") + eq(rewards[BattleTower.SKIPPED_REWARD], nil, "and never rolls LUCKY_PUNCH") + for name in pairs(rewards) do + check(name == "HP_UP" or name == "PROTEIN" or name == "IRON" + or name == "CARBOS" or name == "CALCIUM", + name .. " is inside BATTLETOWER_MIN_REWARD..MAX_REWARD") + end + eq(rewards.HP_UP, true, "and the whole span comes up (HP_UP)") + eq(rewards.CALCIUM, true, "through CALCIUM") + + tower.reward = "CALCIUM" + eq(action(vm, A.GIVEREWARD), 31, "GIVEREWARD answers the item id") + -- A full ITEM pocket that does not already hold the reward: the desk hands + -- over a POTION, which is the script's `ifequal POTION` arm. + for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end + eq(action(vm, A.GIVEREWARD), 18, + "a stuffed pack turns the prize into POTION") +end + +-- ---- Menu_ChallengeExplanationCancel (mobile/mobile_5f.asm:425-468) +do + local save = crystalSave() + for row = 1, 3 do + local vm = towerVm(save, { + scriptMenu = function(_header, done) done(row) end, + }) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, row, "row " .. row .. " comes back as wScriptVar") + end + local vm = towerVm(save, { + scriptMenu = function(_header, done) done(0) end, + }) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, 4, "a B press is the `.Exit` arm's 4") + + vm = towerVm(save) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, 4, "and so is a run with no menu to open") +end + +-- ---- BattleTowerRoomMenu (battle_tower.asm:1-5) +do + local save = crystalSave() + save.hallOfFame = { count = 1, teams = {} } + local pushed + local vm = towerVm(save, { + pushScreen = function(id, opts) + pushed = id + opts.onDone(6) + return true + end, + }) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(pushed, "Gen2BattleTowerMenu", "the desk opens the room menu screen") + eq(vm.scriptVar, 0, "a chosen room answers 0, the script's `ifnotequal $0`") + eq(vm.btLevelGroup, 6, "and wBTChoiceOfLvlGroup holds the L60 room") + + vm = towerVm(save, { + pushScreen = function(_id, opts) + opts.onDone(nil) + return true + end, + }) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.scriptVar, 0x0a, "cancelling answers $a, the desk's loop-back arm") + + vm = towerVm(save) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.scriptVar, 0x0a, "and so does a run with no screen to push") +end + +-- ============================================ the room menu screen itself +do + eq(BattleTowerMenu.levelLabel(1), " L:10 ", "Strings_L10ToL100 row 1") + eq(BattleTowerMenu.levelLabel(10), " L:100", "and row 10, six tiles wide") + + local pressed = {} + local game = { input = { wasPressed = function(_, name) + return pressed[name] == true + end } } + local function press(name) pressed = { [name] = true } end + + local save = crystalSave() + save.hallOfFame = { count = 1, teams = {} } + local result, calls = nil, 0 + local screen = BattleTowerMenu.new(game, { + save = save, + party = { mon("PIKACHU", 20), mon("GEODUDE", 20), mon("HORSEA", 20) }, + onDone = function(value) result = value; calls = calls + 1 end, + }) + eq(screen.cursor, 1, "the spinner opens on L:10") + eq(screen:rowCount(), 11, "ten rooms plus CANCEL") + + press("up"); screen:update() + eq(screen.cursor, 2, "UP walks the level up") + press("down"); screen:update() + press("down"); screen:update() + eq(screen.cursor, 11, "DOWN off the top rolls over to CANCEL") + press("up"); screen:update() + eq(screen.cursor, 1, "and UP off CANCEL rolls back to L:10") + + -- A party at L20 tops the L10 room (mobile/mobile_46.asm:3915-3917). + press("a"); screen:update() + eq(screen.phase, "message", "picking L:10 with L20 mons prints a refusal") + eq(calls, 0, "and does not answer the script") + for _ = 1, 0x80 do pressed = {}; screen:update() end + eq(screen.phase, "pick", "the $80-frame hold puts the menu back") + eq(screen.cursor, 1, "at jumptable index 0, cursor reset") + + press("up"); screen:update() + press("a"); screen:update() + eq(result, 2, "L:20 is accepted and hands back the level group") + eq(calls, 1, "exactly once") +end + +do + -- The uber gate, and the CANCEL path's yes/no. + local pressed = {} + local game = { input = { wasPressed = function(_, name) + return pressed[name] == true + end } } + local function press(name) pressed = { [name] = true } end + + local save = crystalSave() + local result, answered = nil, false + local screen = BattleTowerMenu.new(game, { + save = save, + party = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }, + monName = function(id) return id end, + onDone = function(value) result = value; answered = true end, + }) + eq(screen:rowCount(), 5, "no Hall of Fame, so four rooms plus CANCEL") + for _ = 1, 3 do press("up"); screen:update() end + eq(screen.cursor, 4, "on L:40") + press("a"); screen:update() + eq(screen.phase, "message", "an uber under L70 is refused below the L70 room") + check(screen.message:find("LUGIA", 1, true) ~= nil, + "and the refusal names it, the way text_ram wcd49 does") + + for _ = 1, 0x80 do pressed = {}; screen:update() end + eq(screen.cursor, 1, "the refusal restarts the menu at L:10") + for _ = 1, 4 do press("up"); screen:update() end + eq(screen.cursor, 5, "CANCEL is the last row") + press("a"); screen:update() + eq(screen.phase, "quit", "CANCEL opens the yes/no") + eq(screen.yes, true, "on YES") + press("down"); screen:update() + press("a"); screen:update() + eq(screen.phase, "pick", "NO puts the level spinner back") + eq(answered, false, "and answers nothing yet") + press("b"); screen:update() + eq(screen.phase, "quit", "B on the spinner is the same cancel prompt") + press("a"); screen:update() + eq(answered, true, "YES ends the menu") + eq(result, nil, "with no level group, which the handler turns into $a") +end + +-- ================================ wInBattleTowerBattle and the badge boosts +do + local GROWTH = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, + squared = 0, linear = 0, constant = 0 } } + local BATTLE_DATA = { + pokemon = { + growthRates = GROWTH, + MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }, + PIDGEY = { id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }, + }, + moves = {}, type_chart = { types = {}, matchups = {} }, items = {}, + } + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + + local BADGES = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true, + MINERAL = true, STORM = true, GLACIER = true, RISING = true } + + local function newBattle(tower) + local player = Mon.new(BATTLE_DATA, "MACHOP", 40, { dvs = perfect }) + local wild = Mon.new(BATTLE_DATA, "PIDGEY", 40, { dvs = perfect }) + return Battle.new({ data = BATTLE_DATA, party = { player }, wild = wild, + save = { player = { id = 7, badges = BADGES, kantoBadges = {} } }, + battleTower = tower, + random = function(n) return (n or 1) > 1 and 1 or 0 end }), player + end + + local outside, player = newBattle(false) + eq(outside.inBattleTowerBattle, false, "an ordinary battle is not a Tower one") + eq(outside:battleStat(player, "attack"), + Battle.boostStat(player.stats.attack), + "outside the Tower ZEPHYRBADGE still boosts Attack") + eq(outside:battleStat(player, "speed"), + Battle.boostStat(player.stats.speed), "and PLAINBADGE Speed") + eq(outside:badgeTypeBoost(player, "FLYING"), true, + "and DoBadgeTypeBoosts still fires") + + local inside, towerPlayer = newBattle(true) + eq(inside.inBattleTowerBattle, true, "the Tower battle sets it") + eq(inside:battleStat(towerPlayer, "attack"), towerPlayer.stats.attack, + "BadgeStatBoosts' second early return drops the Attack boost") + eq(inside:battleStat(towerPlayer, "defense"), towerPlayer.stats.defense, + "and Defense") + eq(inside:battleStat(towerPlayer, "speed"), towerPlayer.stats.speed, + "and Speed") + eq(inside:battleStat(towerPlayer, "specialAttack"), + towerPlayer.stats.specialAttack, "and Special Attack") + eq(inside:battleStat(towerPlayer, "specialDefense"), + towerPlayer.stats.specialDefense, + "and GLACIERBADGE's buggy Special Defense re-check with it") + eq(inside:badgeTypeBoost(towerPlayer, "FLYING"), false, + "DoBadgeTypeBoosts takes the same guard (engine/battle/misc.asm:152)") + + -- The obedience ladder shares Battle:hasBadge and the cart does NOT guard + -- it (engine/battle/effect_commands.asm:671-696 reads wJohtoBadges raw). + eq(inside:obedienceLevel(), outside:obedienceLevel(), + "obedience reads the badges either way") + eq(inside:hasBadge("badges", "ZEPHYR"), true, + "and Battle:hasBadge itself is untouched") +end + +-- ================================================ Gold and Silver are clean +do + for _, version in ipairs({ "gold", "silver" }) do + local save = Save.normalize({ version = version, generation = 2 }) + eq(save.version, version, version .. " keeps its own version") + eq(save.battleTower, nil, version .. " grows no battleTower block") + eq(save.crystal, nil, "nor a crystal one") + end + local crystal = Save.normalize({ version = "crystal", generation = 2 }) + eq(crystal.version, "crystal", "and the Crystal file stays Crystal") + check(type(crystal.battleTower) == "table", + "which does carry sBattleTowerChallengeState") + eq(crystal.battleTower.challenge, BattleTower.NO_CHALLENGE, + "starting at BATTLETOWER_NO_CHALLENGE") +end + +S.finish() diff --git a/tests/gen2_battletype_escape_test.lua b/tests/gen2_battletype_escape_test.lua new file mode 100644 index 00000000..be24cc6c --- /dev/null +++ b/tests/gen2_battletype_escape_test.lua @@ -0,0 +1,55 @@ +-- TryToRunAwayFromBattle's battle-type ladder, which nothing covered: +-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI, +-- FORCESHINY and SUICUNE, pokegold/engine/battle/core.asm:3476-3479 only the +-- first and third, and the values themselves come from +-- ../pokecrystal/constants/battle_constants.asm:91-103. +-- luajit tests/gen2_battletype_escape_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battletype escape") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") + +-- ../pokecrystal/constants/battle_constants.asm:91-103, `const_def` from 0. +local NORMAL, CANLOSE, DEBUG, TUTORIAL = 0, 1, 2, 3 +local FISH, ROAMING, CONTEST, FORCESHINY = 4, 5, 6, 7 +local TREE, TRAP, FORCEITEM, CELEBI, SUICUNE = 8, 9, 10, 11, 12 + +eq(Battle.BATTLETYPE_CANLOSE, CANLOSE, "BATTLETYPE_CANLOSE is 1") +eq(Battle.BATTLETYPE_FORCESHINY, FORCESHINY, "BATTLETYPE_FORCESHINY is 7") +eq(Battle.BATTLETYPE_TRAP, TRAP, "BATTLETYPE_TRAP is 9") +eq(Battle.BATTLETYPE_CELEBI, CELEBI, "BATTLETYPE_CELEBI is 11, after FORCEITEM") +eq(Battle.BATTLETYPE_SUICUNE, SUICUNE, "BATTLETYPE_SUICUNE is 12") + +local function refuses(value) + return Battle.noEscapeBattleType({ battleType = value }) == true +end + +for _, row in ipairs({ + { NORMAL, false, "NORMAL" }, + { CANLOSE, false, "CANLOSE" }, + { DEBUG, false, "DEBUG" }, + { TUTORIAL, false, "TUTORIAL" }, + { FISH, false, "FISH" }, + { ROAMING, false, "ROAMING" }, + { CONTEST, false, "CONTEST" }, + { FORCESHINY, true, "FORCESHINY" }, + { TREE, false, "TREE" }, + { TRAP, true, "TRAP" }, + -- ../pokecrystal/maps/TinTower1F.asm:120 and pokegold's Lugia and Ho-Oh both + -- arm FORCEITEM, which the ladder does not name: it is escapable. + { FORCEITEM, false, "FORCEITEM" }, + { CELEBI, true, "CELEBI" }, + { SUICUNE, true, "SUICUNE" }, +}) do + local value, want, name = row[1], row[2], row[3] + eq(refuses(value), want, + ("%s (%d) %s escape"):format(name, value, want and "refuses" or "allows")) +end + +check(refuses(nil) == false, "an unarmed battle escapes") + +S.finish() diff --git a/tests/gen2_chamber_fieldmoves_test.lua b/tests/gen2_chamber_fieldmoves_test.lua new file mode 100644 index 00000000..5a906da1 --- /dev/null +++ b/tests/gen2_chamber_fieldmoves_test.lua @@ -0,0 +1,71 @@ +-- The two Ruins of Alph walls the FIELD MOVES open, at their production seams: +-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash +-- (ZEPHYRBADGE first, SpecialAerodactylChamber second, the darkness test last) +-- and :808-813 EscapeRopeOrDig's `.escaperope` arm. +-- luajit tests/gen2_chamber_fieldmoves_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 chamber field moves") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Events = require("src.world.gen2.Events") +local FieldMoves = require("src.world.gen2.FieldMoves") +local UnownWords = require("src.world.gen2.UnownWords") + +local function ctxFor(mapId, badges, dark) + local events = Events.new() + return { + events = events, + save = { player = { badges = badges } }, + dark = dark or false, + openAerodactylWall = function() + return UnownWords.aerodactylChamber(events, mapId) + end, + }, events +end + +local AERO = UnownWords.CHAMBER_MAPS.AERODACTYL + +-- :281-283, the badge gate ahead of the special. +local ctx, events = ctxFor(AERO, {}, false) +local refused = FieldMoves.fromMenu("FLASH", ctx) +eq(refused.ok, false, "no ZEPHYRBADGE: FLASH is refused in the chamber") +eq(refused.badge, "ZEPHYR", "and it is the badge that refused it") +check(not UnownWords.wallOpened(events, "AERODACTYL"), + "a badgeless press must not reach SpecialAerodactylChamber") + +-- :284-287, the special's carry is a second way into `.useflash`. +ctx, events = ctxFor(AERO, { ZEPHYR = true }, false) +local used = FieldMoves.fromMenu("FLASH", ctx) +eq(used.ok, true, "with the badge FLASH runs in a chamber that is not dark") +eq(used.action, "flash", "and it queues the flash") +check(UnownWords.wallOpened(events, "AERODACTYL"), + "and the wall-opened flag is set") + +-- :288-290, any other lit map still refuses. +ctx, events = ctxFor("RUINS_OF_ALPH_OUTSIDE", { ZEPHYR = true }, false) +eq(FieldMoves.fromMenu("FLASH", ctx).ok, false, "a lit route still refuses") +check(not UnownWords.wallOpened(events, "AERODACTYL"), + "and opens nothing") + +-- A dark cave is the ordinary arm, with no chamber anywhere near it. +ctx = ctxFor("SLOWPOKE_WELL_B1F", { ZEPHYR = true }, true) +eq(FieldMoves.fromMenu("FLASH", ctx).ok, true, "a dark cave is still lit") + +-- A cache with no chamber hook at all (Gold) must behave exactly as before. +eq(FieldMoves.fromMenu("FLASH", { + save = { player = { badges = { ZEPHYR = true } } }, dark = false }).ok, false, + "with no openAerodactylWall on the ctx, a lit map refuses") + +-- ../pokecrystal/engine/events/unown_walls.asm:81, the escape rope's own arm. +local ropeEvents = Events.new() +eq(UnownWords.kabutoChamber(ropeEvents, "RUINS_OF_ALPH_OUTSIDE"), false, + "the rope opens nothing outside a chamber") +check(not UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag still clear") +eq(UnownWords.kabutoChamber(ropeEvents, UnownWords.CHAMBER_MAPS.KABUTO), true, + "and opens the Kabuto wall inside it") +check(UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag set") + +S.finish() diff --git a/tests/gen2_crystal_anim_test.lua b/tests/gen2_crystal_anim_test.lua new file mode 100644 index 00000000..0aca583c --- /dev/null +++ b/tests/gen2_crystal_anim_test.lua @@ -0,0 +1,139 @@ +-- Crystal's animated front pics: the bitmask decode and the frame sequencer. +-- luajit tests/gen2_crystal_anim_test.lua +-- +-- ROM-free. The fixtures are the shapes the extractor writes into +-- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own +-- bitmask/frames data so a failure names the bytes it disagrees with. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal anim") +local check, eq = S.check, S.eq + +local MonAnim = require("src.render.MonAnim") + +-- ---- bitmask decode ------------------------------------------------------- + +-- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1: +-- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the +-- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10, +-- 11, 13, 15 and 16 in the pic's own column-major order. +local BULBASAUR = { + tiles = 5, + bitmasks = { + { 0x60, 0xad, 0x01, 0x00 }, + { 0x20, 0xad, 0x01, 0x00 }, + }, + frames = { + { bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } }, + { bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } }, + }, + play = { { 1, 10 }, { 2, 10 }, { 0, 5 } }, + idle = { { 2, 5 }, { 0, 5 } }, +} + +local base = MonAnim.tileMap(BULBASAUR, 0) +eq(base and #base, 25, "the base picture is 5x5 tiles") +eq(base and base[1], 0, "position 0 is tile 0") +eq(base and base[25], 24, "position 24 is tile 24") + +local first = MonAnim.tileMap(BULBASAUR, 1) +eq(first and #first, 25, "frame 1 is still 5x5 tiles") +local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c, + [12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 } +local wrong = nil +for slot = 1, 25 do + local want = REPLACED[slot] or (slot - 1) + if first[slot] ~= want then wrong = wrong or slot end +end +eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions") + +-- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone. +local second = MonAnim.tileMap(BULBASAUR, 2) +eq(second and second[6], 0x21, "frame 2 replaces position 5") +eq(second and second[7], 6, "and leaves position 6 at its base tile") +eq(second and #BULBASAUR.frames[2].tiles, 7, + "seven set bits, seven replacement tiles") + +eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil, + "no pic size means no tile map") +eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil") + +-- ---- durations ------------------------------------------------------------ + +-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16). +eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone") +eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12") +eq(MonAnim.duration(5, 4), 6, "and 5 to 6") +eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte") + +-- ---- the sequencer -------------------------------------------------------- + +-- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of +-- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm). +local LOOPED = { + tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, + play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } }, + idle = { { 2, 2 } }, +} + +local function timeline(data, scene, ticks) + local anim = MonAnim.new(data, scene) + local out = {} + for _ = 1, ticks do + anim:update() + out[#out + 1] = anim:currentFrame() + end + return out, anim +end + +local frames, anim = timeline(LOOPED, "battle", 14) +eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0", + "the looped script plays twice and lands back on the base picture") +check(anim:finished(), "and the scene is over after PokeAnim_Finish") + +-- Setup costs a frame of its own before the script starts, so the first +-- animation frame is on tick 2, not tick 1. +eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame") + +-- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10. +local ONESHOT = { + tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, + play = { { 1, 8 } }, idle = { { 2, 2 } }, +} +eq(table.concat(timeline(ONESHOT, "battle", 11), ","), + "0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames") +eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","), + "0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten") + +-- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle. +local menu = MonAnim.new(ONESHOT, "menu") +local seen = {} +for tick = 1, 31 do + menu:update() + seen[tick] = menu:currentFrame() +end +eq(seen[10], 0, "the entrance script ends on the base picture") +local resume +for tick = 11, 31 do + if seen[tick] ~= 0 then resume = tick break end +end +eq(resume, 11 + MonAnim.SCENE_WAIT + 1, + "eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script") +eq(resume and seen[resume], 2, "and the idle script's first frame comes up") +check(not menu:finished(), "the menu scene is longer than the battle one") + +local ran = 0 +for tick = 1, 60 do + menu:update() + if menu:finished() then ran = tick break end +end +check(ran > 0, "but it does end") + +-- ---- gating --------------------------------------------------------------- + +eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer") +eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil, + "and an empty script means no sequencer, which is every Gold species") +eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too") + +S.finish() diff --git a/tests/gen2_crystal_caught_data_test.lua b/tests/gen2_crystal_caught_data_test.lua new file mode 100644 index 00000000..064dc710 --- /dev/null +++ b/tests/gen2_crystal_caught_data_test.lua @@ -0,0 +1,314 @@ +-- Crystal MON_CAUGHTDATA: the two packed bytes, who stamps them, and the two +-- readers that key off them (the level-up happiness pick and the fields that +-- have to survive an evolution and a Day-Care round trip). +-- luajit tests/gen2_crystal_caught_data_test.lua +-- +-- Gold has no such word in its party struct, so every assertion below comes in +-- a Crystal half and a Gold half: Gold must come out with the three fields +-- still nil. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal caught data") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local Catching = require("src.battle.gen2.Catching") +local Evolution = require("src.core.gen2.Evolution") +local Breeding = require("src.core.gen2.Breeding") +local Happiness = require("src.core.gen2.Happiness") +local Save = require("src.core.gen2.Save") + +local priorVersion = GameVersion.get() + +-- ---- fixtures ------------------------------------------------------------- + +-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm). +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local function species(id, index, evolutions) + return { + id = id, index = index, name = id, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + types = { "NORMAL", "NORMAL" }, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = 127, + eggGroups = { "EGG_GROUND", "EGG_GROUND" }, + eggSteps = 20, + evolutions = evolutions or {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + } +end + +local DATA = { + moves = { TACKLE = { id = "TACKLE", pp = 35 } }, + pokemon = { + growthRates = GROWTH, + CATERPIE = species("CATERPIE", 10, + { { method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" } }), + METAPOD = species("METAPOD", 11), + }, + -- data/generated/landmarks.lua's shape, two rows deep enough for the + -- National Park override to resolve by name. + gen2Landmarks = { + landmarks = { + LANDMARK_NATIONAL_PARK = { id = "LANDMARK_NATIONAL_PARK", index = 19 }, + LANDMARK_NEW_BARK_TOWN = { id = "LANDMARK_NEW_BARK_TOWN", index = 1 }, + }, + }, +} + +local function newMon(level, opts) + return Mon.new(DATA, "CATERPIE", level or 5, opts) +end + +-- ---- the constants -------------------------------------------------------- + +eq(Mon.CAUGHT_TIME_MASK, 0xc0, "CAUGHT_TIME_MASK") +eq(Mon.CAUGHT_LEVEL_MASK, 0x3f, "CAUGHT_LEVEL_MASK") +eq(Mon.CAUGHT_GENDER_MASK, 0x80, "CAUGHT_GENDER_MASK") +eq(Mon.CAUGHT_LOCATION_MASK, 0x7f, "CAUGHT_LOCATION_MASK") +eq(Mon.CAUGHT_EGG_LEVEL, 1, "CAUGHT_EGG_LEVEL") +eq(Mon.LANDMARK_EVENT, 0x7f, "LANDMARK_EVENT") +eq(Mon.LANDMARK_GIFT, 0x7e, "LANDMARK_GIFT") + +-- ---- the two bytes -------------------------------------------------------- + +-- `ld a, [wTimeOfDay] / inc a / rrca / rrca`: MORN 0 stores as 1, and 0 is +-- left free to mean unknown. +eq(Mon.caughtTimeOf(0), 1, "MORN stamps 1") +eq(Mon.caughtTimeOf(1), 2, "DAY stamps 2") +eq(Mon.caughtTimeOf(2), 3, "NITE stamps 3") +eq(Mon.caughtTimeOf("MORN"), 1, "the name reads the same as the id") +eq(Mon.caughtTimeOf(nil), 0, "and nothing at all is unknown") +eq(Mon.caughtTimeOf("BRUNCH"), 0, "as is a time of day that is not one") + +local stamped = Mon.setCaughtData({ level = 12 }, + { timeOfDay = 2, landmark = 1, playerGender = "male" }) +eq(stamped.caughtTime, 3, "NITE") +eq(stamped.caughtLevel, 12, "the level the catch happened at") +eq(stamped.caughtLocation, 1, "GetWorldMapLocation's landmark") +eq(stamped.caughtByGender, "boy", "and wPlayerGender bit 0 clear") + +local byte0, byte1 = Mon.packCaughtData(stamped) +eq(byte0, 3 * 0x40 + 12, "byte 0 is ((tod + 1) << 6) | level") +eq(byte1, 1, "byte 1 is (female << 7) | landmark") + +local female = Mon.setCaughtData({}, + { timeOfDay = 0, level = 7, landmark = 19, playerGender = "female" }) +eq(female.caughtByGender, "girl", "PLAYERGENDER_FEMALE_F reads as girl") +byte0, byte1 = Mon.packCaughtData(female) +eq(byte0, 0x40 + 7, "MORN and level 7") +eq(byte1, 0x80 + 19, "the gender bit rides byte 1, not byte 0") + +-- CAUGHT_LEVEL_MASK is six bits, so the packed level of a high-level catch +-- wraps exactly as `or b` leaves it on the cart. +byte0 = Mon.packCaughtData(Mon.setCaughtData({}, + { timeOfDay = 1, level = 70, landmark = 1 })) +eq(byte0 % 0x40, 70 % 0x40, "level 70 packs into six bits") + +local round = Mon.unpackCaughtData(Mon.packCaughtData(female)) +eq(round.caughtTime, 1, "unpack recovers the time") +eq(round.caughtLevel, 7, "the level") +eq(round.caughtLocation, 19, "the location") +eq(round.caughtByGender, "girl", "and the gender bit") + +local blank = Mon.unpackCaughtData(0, 0) +eq(blank.caughtTime, 0, "an all-zero word is an unknown time") +eq(blank.caughtLevel, 0, "an unknown level") +eq(blank.caughtLocation, 0, "and an unknown location") + +-- SetGiftMonCaughtData folds CAUGHT_BY_* through `rrc b`, which is why +-- CAUGHT_BY_BOY lands on LANDMARK_EVENT instead of on a gender bit. +local gift = Mon.setGiftCaughtData({}, "girl") +eq(gift.caughtTime, 0, "a gift mon has no caught time") +eq(gift.caughtLevel, 0, "and no caught level") +eq(gift.caughtLocation, Mon.LANDMARK_GIFT, "LANDMARK_GIFT") +eq(gift.caughtByGender, "girl", "with the gender bit set") +eq(Mon.setGiftCaughtData({}, "boy").caughtLocation, Mon.LANDMARK_EVENT, + "CAUGHT_BY_BOY rotates into bit 0 and makes it LANDMARK_EVENT") +eq(Mon.setGiftCaughtData({}, "unknown").caughtLocation, Mon.LANDMARK_GIFT, + "CAUGHT_BY_UNKNOWN leaves LANDMARK_GIFT alone") + +-- ---- who stamps ----------------------------------------------------------- + +GameVersion.set("gold") +check(not Mon.hasCaughtData(), "Gold's struct has no MON_CAUGHTDATA") +local goldMon = newMon(5) +eq(goldMon.caughtLevel, 5, "Gold still gets the caught level it always had") +eq(goldMon.caughtTime, nil, "but no caught time") +eq(goldMon.caughtLocation, nil, "no caught location") +eq(goldMon.caughtByGender, nil, "and no OT gender") +Catching.stampCaughtData(goldMon, + { data = DATA, timeOfDay = 1, map = { id = "ROUTE_29", landmark = 1 } }) +eq(goldMon.caughtTime, nil, "and SetCaughtData is a no-op on Gold") + +GameVersion.set("crystal") +check(Mon.hasCaughtData(), "the crystal lineage carries it") + +local caught = newMon(4) +Catching.stampCaughtData(caught, { + data = DATA, timeOfDay = 1, playerGender = "female", + map = { id = "ROUTE_29", landmark = 1 }, +}) +eq(caught.caughtTime, 2, "a Crystal catch stamps the time") +eq(caught.caughtLevel, 4, "the level") +eq(caught.caughtLocation, 1, "the map's landmark") +eq(caught.caughtByGender, "girl", "and the player's gender") + +-- The Pokecenter 2F substitution: wBackupMapGroup / wBackupMapNumber, so the +-- cable-club floor never lands on LANDMARK_SPECIAL. +eq(Catching.caughtLandmark({ + map = { id = "POKECENTER_2F", landmark = 0 }, + backupMap = { id = "VIOLET_CITY", landmark = 6 }, +}), 6, "POKECENTER_2F reads the map it was entered from") +eq(Catching.caughtLandmark({ map = { id = "POKECENTER_2F", landmark = 0 } }), 0, + "with no backup map recorded it stays unknown") +eq(Catching.caughtLandmark({ map = { id = "ROUTE_29", landmark = 1 } }), 1, + "every other map is a plain GetWorldMapLocation") +eq(Catching.caughtLandmark({}), 0, "and no map at all is unknown") + +-- BugContest_SetCaughtContestMon overwrites the location afterwards, keeping +-- the gender bit. +eq(Catching.caughtLandmark({ data = DATA, bugContest = true, + map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 } }), 19, + "the Bug Contest catch is LANDMARK_NATIONAL_PARK") +eq(Catching.caughtLandmark({ bugContest = true }), + Catching.LANDMARK_NATIONAL_PARK, + "and the same index without a landmarks table to look it up in") + +local contest = Catching.stampCaughtData(newMon(6), { + data = DATA, bugContest = true, timeOfDay = 1, playerGender = "female", + map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 }, +}) +eq(contest.caughtLocation, 19, "the contest mon is stamped National Park") +eq(contest.caughtByGender, "girl", "and keeps CAUGHT_GENDER_MASK") + +-- ---- survival across an evolution ----------------------------------------- + +check(Evolution.MON_FIELDS.caughtTime, "MON_FIELDS names caughtTime") +check(Evolution.MON_FIELDS.caughtLocation, "and caughtLocation") +check(Evolution.MON_FIELDS.caughtByGender, "and caughtByGender") + +local evolved = Evolution.apply(DATA, caught, + { method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" }) +eq(evolved.species, "METAPOD", "it evolved") +eq(evolved.caughtTime, 2, "the caught time survived") +eq(evolved.caughtLevel, 4, "the caught level survived") +eq(evolved.caughtLocation, 1, "the caught location survived") +eq(evolved.caughtByGender, "girl", "and so did the OT gender") + +-- ---- survival across the Day-Care, and the egg marker --------------------- + +local function newSave(party) + local save = { version = "crystal", party = party or {}, + player = { name = "CHRIS", id = 1, gender = "female", money = 5000 }, + pokedex = { seen = {}, caught = {} } } + return save +end + +local deposited = newMon(5) +Catching.stampCaughtData(deposited, { + data = DATA, timeOfDay = 2, playerGender = "female", + map = { id = "ROUTE_34", landmark = 3 }, +}) +local save = newSave({}) +local dc = Breeding.dayCare(save) +dc.man.mon = deposited +dc.man.level = 5 +local ok, withdrawn = Breeding.withdraw(DATA, save, "man") +check(ok ~= false, "the mon came back out of the Day-Care") +withdrawn = save.party[1] +eq(withdrawn.caughtTime, 3, "RetrieveBreedmon carried the caught time") +eq(withdrawn.caughtLevel, 5, "the caught level") +eq(withdrawn.caughtLocation, 3, "the caught location") +eq(withdrawn.caughtByGender, "girl", "and the OT gender") + +local egg = newMon(5) +egg.isEgg = true +egg.eggSteps = 0 +egg.level = 5 +save = newSave({ egg }) +local hatched = Breeding.hatch(DATA, save, 1, nil, + { timeOfDay = 0, landmark = 3 }) +check(hatched ~= nil, "the egg hatched") +eq(hatched.caughtLevel, Mon.CAUGHT_EGG_LEVEL, + "SetEggMonCaughtData stamps CAUGHT_EGG_LEVEL, not the hatch level") +eq(hatched.caughtTime, 1, "with the hatch site's time of day") +eq(hatched.caughtLocation, 3, "and its landmark") +eq(hatched.caughtByGender, "girl", "off save.player.gender") +eq(Mon.packCaughtData(hatched) % 0x40, Mon.CAUGHT_EGG_LEVEL, + "so byte 0's level field reads 1") + +GameVersion.set("gold") +local goldEgg = newMon(5) +goldEgg.isEgg = true +goldEgg.eggSteps = 0 +local goldSave = newSave({ goldEgg }) +goldSave.version = "gold" +goldSave.player.gender = nil +local goldHatch = Breeding.hatch(DATA, goldSave, 1) +eq(goldHatch.caughtLevel, 5, "a Gold hatchling keeps the egg's own level") +eq(goldHatch.caughtTime, nil, "and grows no caught-data keys") +eq(goldHatch.caughtLocation, nil, "none of them") +eq(goldHatch.caughtByGender, nil, "at all") +GameVersion.set("crystal") + +-- ---- HAPPINESS_GAINLEVELATHOME -------------------------------------------- + +eq(Happiness.EVENT.GAINLEVELATHOME, 19, "the enum's 19th row") +eq(Happiness.NUM_EVENTS, 19, "and Crystal's table is one longer than Gold's") +eq(#Happiness.CHANGES, 19, "every row is present") +eq(Happiness.delta("GAINLEVELATHOME", 0), 10, "+10 under 100") +eq(Happiness.delta("GAINLEVELATHOME", 100), 6, "+6 under 200") +eq(Happiness.delta("GAINLEVELATHOME", 200), 4, "+4 above it") +check(Happiness.delta("GAINLEVELATHOME", 0) > Happiness.delta("GAINLEVEL", 0), + "and it is worth more than a plain level") + +local home = { happiness = 70, caughtLocation = 3 } +eq(Happiness.levelUpEvent(home, 3), "GAINLEVELATHOME", + "levelling where it was caught") +eq(Happiness.levelUpEvent(home, 4), "GAINLEVEL", "and anywhere else") +eq(Happiness.levelUpEvent({ caughtLocation = 3 }, nil), "GAINLEVEL", + "an unknown current landmark cannot match") +eq(Happiness.levelUpEvent({}, 0), "GAINLEVEL", + "and a mon with no caught data at all never reads ATHOME") +eq(Happiness.levelUpEvent({ caughtLocation = 0 }, 0), "GAINLEVELATHOME", + "though a Crystal mon stamped LANDMARK_SPECIAL does, as the cart's cp does") +-- CAUGHT_LOCATION_MASK: the comparison is against the low seven bits, so the +-- gender bit riding the same byte cannot break the match. +eq(Happiness.levelUpEvent({ caughtLocation = 0x80 + 3 }, 3), "GAINLEVELATHOME", + "the gender bit is masked out of the comparison") + +eq(Happiness.levelUp(home, 3), 80, "levelUp applies the +10") +eq(Happiness.levelUp(home, 9), 85, "and the +5 away from home") + +-- ---- the save layer ------------------------------------------------------- + +eq(Save.defaultPlayerName("gold"), "GOLD", "PlayerNameArray's first row") +eq(Save.defaultPlayerName("silver"), "SILVER", "on Silver") +eq(Save.defaultPlayerName("crystal"), "CHRIS", "and MalePlayerNameArray's") + +local normalized = Save.normalize({ version = "crystal", player = {} }) +eq(normalized.player.gender, "male", "normalize defaults wPlayerGender to 0") +eq(Save.normalize({ version = "crystal", + player = { gender = "female" } }).player.gender, "female", + "and leaves a recorded gender alone") + +-- A Gold save that never catches anything grows no new keys. +GameVersion.set("gold") +local goldParty = { newMon(5) } +local goldFile = Save.normalize({ version = "gold", party = goldParty }) +eq(goldFile.party[1].caughtTime, nil, "no caughtTime on a Gold record") +eq(goldFile.party[1].caughtLocation, nil, "no caughtLocation") +eq(goldFile.party[1].caughtByGender, nil, "no caughtByGender") + +GameVersion.set(priorVersion) + +S.finish() diff --git a/tests/gen2_crystal_extras_test.lua b/tests/gen2_crystal_extras_test.lua new file mode 100644 index 00000000..23fe2ae9 --- /dev/null +++ b/tests/gen2_crystal_extras_test.lua @@ -0,0 +1,559 @@ +-- ../pokecrystal/engine/events/move_tutor.asm:1, engine/events/buena.asm:1 +-- and :64, engine/events/poke_seer.asm:18, mobile/mobile_12_2.asm:191. +-- ROM-free: +-- luajit tests/gen2_crystal_extras_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- The same love stub tests/gen2_menus_test.lua installs; nothing here draws. +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 1 end } +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +local S = require("tests.harness").suite("gen2 crystal extras") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local Events = require("src.world.gen2.Events") +local Mon = require("src.battle.gen2.Mon") +local MoveTutorScreen = require("src.ui.gen2.MoveTutor") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") + +local H = Specials.HANDLERS + +-- --------------------------------------------------------------- fixtures + +-- The cache tables the handlers read, plus the landmark registry +-- GetLandmarkName walks (../pokecrystal/engine/overworld/landmarks.asm:16). +local DATA = { + pokemon = { + tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" }, + TYPHLOSION = { index = 157, name = "TYPHLOSION", + tmhm = { "CUT", "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, + GEODUDE = { index = 74, name = "GEODUDE", + tmhm = { "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, + LAPRAS = { index = 131, name = "LAPRAS", tmhm = { "SURF" } }, + CYNDAQUIL = { index = 155, name = "CYNDAQUIL" }, + TOTODILE = { index = 158, name = "TOTODILE" }, + CHIKORITA = { index = 152, name = "CHIKORITA" }, + PIKACHU = { index = 25, name = "PIKACHU" }, + RATTATA = { index = 19, name = "RATTATA" }, + HOOTHOOT = { index = 163, name = "HOOTHOOT" }, + SPINARAK = { index = 165, name = "SPINARAK" }, + DROWZEE = { index = 96, name = "DROWZEE" }, + }, + items = { + ULTRA_BALL = { index = 2, name = "ULTRA BALL" }, + FULL_RESTORE = { index = 16, name = "FULL RESTORE" }, + NUGGET = { index = 92, name = "NUGGET" }, + RARE_CANDY = { index = 50, name = "RARE CANDY" }, + PROTEIN = { index = 33, name = "PROTEIN" }, + IRON = { index = 34, name = "IRON" }, + CARBOS = { index = 35, name = "CARBOS" }, + CALCIUM = { index = 37, name = "CALCIUM" }, + HP_UP = { index = 32, name = "HP UP" }, + POTION = { index = 17, name = "POTION" }, + ANTIDOTE = { index = 18, name = "ANTIDOTE" }, + PARLYZ_HEAL = { index = 21, name = "PARLYZ HEAL" }, + FRESH_WATER = { index = 39, name = "FRESH WATER" }, + SODA_POP = { index = 40, name = "SODA POP" }, + LEMONADE = { index = 41, name = "LEMONADE" }, + POKE_BALL = { index = 5, name = "POKé BALL" }, + GREAT_BALL = { index = 1, name = "GREAT BALL" }, + X_ATTACK = { index = 68, name = "X ATTACK" }, + X_DEFEND = { index = 69, name = "X DEFEND" }, + X_SPEED = { index = 67, name = "X SPEED" }, + }, + moves = { + FLAMETHROWER = { name = "FLAMETHROWER" }, + THUNDERBOLT = { name = "THUNDERBOLT" }, + ICE_BEAM = { name = "ICE BEAM" }, + TACKLE = { name = "TACKLE" }, + GROWL = { name = "GROWL" }, + MUD_SLAP = { name = "MUD-SLAP" }, + }, + gen2Landmarks = { + order = {}, + landmarks = { + NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" }, + ROUTE_29 = { index = 2, name = "ROUTE 29" }, + }, + }, +} + +local function newSave() + local save = { + version = "crystal", + player = { name = "KRIS", id = 0x1234, money = 0 }, + party = {}, + inventory = {}, + bagOrder = {}, + } + Save.crystalState(save) + return save +end + +-- World:specialHooks, stubbed; every screen answers through its own onDone. +local function newHooks(save, opts) + opts = opts or {} + local vars = opts.vars or {} + local log = { pushed = {}, sfx = {} } + local hooks = { + log = log, + vars = vars, + save = function() return save end, + data = function() return DATA end, + party = function() return save.party end, + itemIndex = function(id) + local def = DATA.items[id] + return def and def.index + end, + hasItem = function(index) return opts.held and opts.held[index] == true end, + playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end, + selectPartyMon = function(prompt, done) + log.selectPrompt = prompt + done(opts.pickIndex, opts.pickIndex and save.party[opts.pickIndex]) + end, + pushScreen = function(id, screenOpts) + log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts } + if opts.noScreens then return false end + local answer = opts.answers and opts.answers[#log.pushed] + if screenOpts.onDone then screenOpts.onDone(answer) end + return true + end, + } + return hooks +end + +-- The var pair is Vm.readVarFn / Vm.writeVarFn, not a `specials` hook: +-- engine/overworld/variables.asm's rows belong to `readvar` / `writevar`. +local function newVm(hooks, scriptVar) + local vars = hooks.vars + local vm = Vm.new({}, {}, Events.new(), { + specials = hooks, + readVar = function(id) return vars[id] or 0 end, + writeVar = function(id, value) vars[id] = value end, + }) + vm.showTextFn = function() end + vm.scriptVar = scriptVar or 0 + return vm +end + +-- Run one handler out, collecting its pages and feeding its YES/NO answers. +local function run(vm, handler, answers) + answers = answers or {} + local pages, asked = {}, 0 + local co = coroutine.create(function() handler(vm) end) + vm.co = co + local send + while true do + local ok, req = coroutine.resume(co, send) + if not ok then error(req, 0) end + if coroutine.status(co) == "dead" then break end + send = nil + if type(req) == "table" and req.kind == "text" then + pages[#pages + 1] = req.text + elseif type(req) == "table" and req.kind == "yesorno" then + asked = asked + 1 + send = answers[asked] + else + pages.parked = req + break + end + end + return pages +end + +-- ============================================================ the move tutor + +-- .GetMoveTutorMove (engine/events/move_tutor.asm:36-52); anything that is +-- not MOVETUTOR_FLAMETHROWER or _THUNDERBOLT falls through to MT03. +do + local function tutorMoveFor(value, answer) + local save = newSave() + local hooks = newHooks(save, { answers = { answer } }) + local vm = newVm(hooks, value) + run(vm, H.MoveTutor) + local pushed = hooks.log.pushed[1] + return pushed and pushed.opts.move, vm.scriptVar + end + eq(select(1, tutorMoveFor(1, true)), "FLAMETHROWER", "MOVETUTOR_FLAMETHROWER is MT01") + eq(select(1, tutorMoveFor(2, true)), "THUNDERBOLT", "MOVETUTOR_THUNDERBOLT is MT02") + eq(select(1, tutorMoveFor(3, true)), "ICE_BEAM", "MOVETUTOR_ICE_BEAM is MT03") + eq(select(1, tutorMoveFor(0, true)), "ICE_BEAM", + "and anything else falls through to MT03, as the `cp` ladder does") + + local _, learned = tutorMoveFor(1, true) + eq(learned, 0, "a taught move leaves wScriptVar FALSE, which is .TeachMove") + local _, cancelled = tutorMoveFor(1, false) + eq(cancelled, 255, "and a cancel leaves -1, which is .Incompatible") +end + +-- engine/events/move_tutor.asm:29 .cancel, ahead of the script's +-- takecoins 4000 (maps/GoldenrodCity.asm:124). +do + local save = newSave() + local hooks = newHooks(save, { noScreens = true }) + local vm = newVm(hooks, 2) + run(vm, H.MoveTutor) + eq(vm.scriptVar, 255, "no screen is the -1 cancel, not a free lesson") +end + +-- `add_mt` gives the three tutor moves TMHM flags 58-60 +-- (constants/item_constants.asm:295-307), so CanLearnTMHMMove sees them. +do + local view = MoveTutorScreen.speciesView(DATA.pokemon) + check(MoveTutorScreen.canLearn(view.TYPHLOSION, "FLAMETHROWER"), + "TYPHLOSION can be taught FLAMETHROWER") + check(MoveTutorScreen.canLearn(view.TYPHLOSION, "CUT"), + "and its ordinary TM list still answers") + check(not MoveTutorScreen.canLearn(view.LAPRAS, "FLAMETHROWER"), + "LAPRAS cannot") + local merged = view.TYPHLOSION.tmhm + eq(#merged, 3, "the view's tmhm list carries the tutor row too") + eq(merged[3], "FLAMETHROWER", "appended after the TM/HM rows") + check(view.TYPHLOSION == view.TYPHLOSION, "and the view is memoised") + eq(view.MISSINGNO, nil, "a species the cache does not carry stays nil") + eq(#DATA.pokemon.TYPHLOSION.tmhm, 2, "the cache's own record is not touched") +end + +-- ================================================================== Buena + +-- engine/events/buena_menu.asm:1-9: carry (NO or B) is 0, YES is 1. +do + local save = newSave() + local vm = newVm(newHooks(save)) + run(vm, H.AskRememberPassword, { true }) + eq(vm.scriptVar, 1, "YES is 1, which the script reads as `iffalse` not taken") + local vm2 = newVm(newHooks(save)) + run(vm2, H.AskRememberPassword, { false }) + eq(vm2.scriptVar, 0, "and NO is 0, .ForgotPassword") +end + +-- engine/events/buena.asm:19-23: only wBuenasPassword's low nybble is right. +do + local save = newSave() + -- BuenasPassword4's two rejection rolls, pinned + -- (engine/pokegear/radio.asm:1470-1487). + local rolls = { 1, 2 } + local taken = 0 + local realRandom = Specials.random + Specials.random = function() taken = taken + 1 return rolls[taken] end + + local hooks = newHooks(save, { answers = { 1 } }) + local vm = newVm(hooks) + run(vm, H.BuenasPassword) + Specials.random = realRandom + + local pushed = hooks.log.pushed[1] + eq(pushed.id, "Gen2BuenaPassword", "the show opens its own menu") + eq(pushed.opts.mode, "password", "in password mode") + eq(pushed.opts.width, 10, "the box is as wide as the category's points byte") + eq(table.concat(pushed.opts.words, ","), "CYNDAQUIL,TOTODILE,CHIKORITA", + ".PlacePasswordChoices resolves BUENA_MON rows through GetPokemonName") + eq(vm.scriptVar, 1, "picking the low nybble's row is the right answer") + eq(save.crystal.buenaPassword.word, 0x01, + "and the roll is packed group-high, word-low into wBuenasPassword") + eq(hooks.vars[0x19], 0x01, "VAR_BUENASPASSWORD sees the same byte") + + -- DAILYFLAGS2_BUENAS_PASSWORD_F holds the roll for the day + -- (engine/pokegear/radio.asm:1467). + local hooks2 = newHooks(save, { answers = { 0 } }) + local vm2 = newVm(hooks2) + run(vm2, H.BuenasPassword) + eq(vm2.scriptVar, 0, "row 0 is a wrong guess") + eq(save.crystal.buenaPassword.word, 0x01, "and the day's password is unchanged") + eq(table.concat(hooks2.log.pushed[1].opts.words, ","), + "CYNDAQUIL,TOTODILE,CHIKORITA", "so the menu offers the same three words") +end + +-- GetBuenasPassword's four BUENA_* arms (engine/pokegear/radio.asm:1534), +-- and the points byte as the menu width (engine/events/buena.asm:9-12). +do + local save = newSave() + local realRandom = Specials.random + local function pin(group, word) + local rolls, taken = { group + 1, word + 1 }, 0 + Specials.random = function() taken = taken + 1 return rolls[taken] end + save.crystal.buenaPassword.word = nil + save.crystal.buenaPassword.day = nil + local hooks = newHooks(save, { answers = { word } }) + local vm = newVm(hooks) + run(vm, H.BuenasPassword) + return hooks.log.pushed[1].opts, vm.scriptVar + end + local balls = pin(3, 2) + eq(table.concat(balls.words, ","), "POKé BALL,GREAT BALL,ULTRA BALL", + "BUENA_ITEM rows resolve through GetItemName") + eq(balls.width, 12, "and .Balls is worth 12 points") + local towns = pin(6, 0) + eq(table.concat(towns.words, ","), "NEW BARK TOWN,CHERRYGROVE CITY,AZALEA TOWN", + "BUENA_STRING rows are the literals themselves") + eq(towns.width, 16, "the widest category is 16 wide") + local moves = pin(8, 1) + eq(table.concat(moves.words, ","), "TACKLE,GROWL,MUD-SLAP", + "BUENA_MOVE rows resolve through GetMoveName") + local types = pin(7, 0) + eq(types.width, 6, ".Types is the narrowest box at 6") + local _, right = pin(10, 2) + eq(right, 1, "the eleventh category exists and its third row can be picked") + Specials.random = realRandom +end + +-- engine/events/buena.asm:25 .wrong is what a menu-less run has to take. +do + local save = newSave() + local vm = newVm(newHooks(save, { noScreens = true })) + run(vm, H.BuenasPassword) + eq(vm.scriptVar, 0, "no menu means no correct answer") +end + +-- ------------------------------------------------------------ the prizes + +-- data/items/buena_prizes.asm, and the cost / ReceiveItem / subtract order +-- (engine/events/buena.asm:95-119). +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 5 }, answers = { 1 } }) + local vm = newVm(hooks) + local pages = run(vm, H.BuenaPrize, { true }) + + local pushed = hooks.log.pushed[1] + eq(pushed.opts.mode, "prize", "the counter opens the prize list") + eq(#pushed.opts.prizes, 9, "NUM_BUENA_PRIZES rows") + eq(pushed.opts.prizes[1].name, "ULTRA BALL", "the first prize") + eq(pushed.opts.prizes[1].cost, 2, "costs 2 points") + eq(pushed.opts.prizes[9].name, "HP UP", "the last prize") + eq(pushed.opts.prizes[9].cost, 5, "costs 5") + eq(pushed.opts.balance, 5, "and the Points box shows wBlueCardBalance") + + eq(save.inventory.ULTRA_BALL, 1, "one ULTRA BALL leaves the counter") + eq(hooks.vars[0x18], 3, "and the two points come off the card") + eq(hooks.log.sfx[1], "Sfx_Transaction", "SFX_TRANSACTION rings the sale") + eq(pages[#pages], "Oh. Please come\nback again!", + ".done prints _BuenaComeAgainText after the loop") +end + +-- engine/events/buena.asm:93 `jr c, .loop`: nothing is spent. +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) + local vm = newVm(hooks) + run(vm, H.BuenaPrize, { false }) + eq(save.inventory.ULTRA_BALL, nil, "a refused confirmation buys nothing") + eq(hooks.vars[0x18], 9, "and spends no points") + eq(#hooks.log.pushed, 2, "the list reopens once before the B press ends it") +end + +-- .InsufficientBalance and .BagFull (engine/events/buena.asm:121-127). +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 1 }, answers = { 1, 0 } }) + local vm = newVm(hooks) + local pages = run(vm, H.BuenaPrize, { true }) + eq(save.inventory.ULTRA_BALL, nil, "one point cannot buy a two point ball") + eq(hooks.vars[0x18], 1, "the balance is untouched") + check(pages[3] == "You don't have\nenough points.", + "_BuenaNotEnoughPointsText is the refusal") + + -- ReceiveItem's own refusal: a stack already at 99. + local full = newSave() + full.inventory.ULTRA_BALL = 99 + local fullHooks = newHooks(full, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) + local fullVm = newVm(fullHooks) + local fullPages = run(fullVm, H.BuenaPrize, { true }) + eq(full.inventory.ULTRA_BALL, 99, "a full stack takes no more") + eq(fullHooks.vars[0x18], 9, "a full bag spends nothing either") + check(fullPages[3] == "You have no room\nfor it.", + "_BuenaNoRoomText is that refusal") +end + +-- ================================================================ the Seer + +local function seerMon(fields) + local mon = { + species = "GEODUDE", nickname = "ROCKY", level = 30, + otId = 0x1234, otName = "KRIS", moves = {}, + } + for key, value in pairs(fields or {}) do mon[key] = value end + return mon +end + +local function runSeer(mon, cancel) + local save = newSave() + save.party = { mon } + local hooks = newHooks(save, { pickIndex = (not cancel) and 1 or nil }) + local vm = newVm(hooks) + return run(vm, H.PokeSeer), hooks +end + +-- engine/events/poke_seer.asm:38 .cancel, SeerDoNothingText. +do + local pages = runSeer(seerMon(), true) + eq(#pages, 2, "the intro and then the refusal") + eq(pages[2], "Fufufu! I saw that\nyou'd do nothing!", "_SeerDoNothingText") +end + +-- engine/events/poke_seer.asm:28-29 `cp EGG / jr z, .egg`. +do + local pages = runSeer(seerMon({ isEgg = true })) + eq(pages[2], "Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…", + "_SeerEggText") +end + +-- ReadCaughtData's `.error` (engine/events/poke_seer.asm:104-105, :133). +do + local pages = runSeer(seerMon()) + check(pages[2]:find("Whaaaat", 1, true) ~= nil, + "a mon with no caught data at all gets _SeerCantTellAThingText") +end + +-- SeerAction0 (engine/events/poke_seer.asm:64-70), then SeerAdvice. +do + local mon = seerMon({ caughtTime = 1, caughtLevel = 10, + caughtLocation = 1, caughtByGender = "boy" }) + local pages = runSeer(mon) + eq(pages[2], "Hm… I see you met\nROCKY here:\vNEW BARK TOWN!", + "_SeerNameLocationText, with the town map's line break spent as a space") + eq(pages[3], "The time was\nMorning!\fIts level was 10!\fAm I good or what?", + "_SeerTimeLevelText") + check(pages[4]:find("more confident", 1, true) ~= nil, + "a 20 level gain is SeerMoreConfidentText, the 29 row") +end + +-- SeerAction1, and engine/events/poke_seer.asm:110-119 where the OT id's +-- second `cp` is commented out, so only the HIGH byte decides. +do + local traded = seerMon({ otId = 0x9999, otName = "SILVER", + caughtTime = 3, caughtLevel = 5, caughtLocation = 2 }) + local pages = runSeer(traded) + eq(pages[2], "Hm… ROCKY\ncame from SILVER\vin a trade?\fROUTE 29\n" + .. "was where SILVER\vmet ROCKY!", "_SeerTradeText") + eq(pages[3], "The time was\nNight!\fIts level was 5!\fAm I good or what?", + "the same time/level page follows") + + local sameHigh = seerMon({ otId = 0x12ff, caughtTime = 2, caughtLevel = 5, + caughtLocation = 2 }) + local samePages = runSeer(sameHigh) + check(samePages[2]:find("I see you met", 1, true) ~= nil, + "an OT id that differs only in its LOW byte reads as met, bug and all") +end + +-- GetCaughtLocation's two sentinels (engine/events/poke_seer.asm:239-249). +do + local event = seerMon({ caughtLevel = 20, caughtLocation = Mon.LANDMARK_EVENT }) + local pages = runSeer(event) + check(pages[2]:find("What!? Incredible!", 1, true) ~= nil, + "LANDMARK_EVENT is SEERACTION_LEVEL_ONLY, _SeerNoLocationText") + check(pages[2]:find("was at level 20", 1, true) ~= nil, + "which still prints the level") + check(pages[3] ~= nil, "and still gives the advice page") + + local gift = seerMon({ caughtLevel = 5, caughtLocation = Mon.LANDMARK_GIFT }) + local giftPages = runSeer(gift) + check(giftPages[2]:find("Whaaaat", 1, true) ~= nil, + "LANDMARK_GIFT is SEERACTION_CANT_TELL_2, the same refusal") + eq(#giftPages, 2, "and there is no advice behind it") +end + +-- GetCaughtLevel (engine/events/poke_seer.asm:148-179) and GetCaughtTime's +-- .none arm (:198-201). +do + local unknown = seerMon({ caughtLevel = 0, caughtLocation = 1, caughtTime = 0 }) + -- byte1 is set, so ReadCaughtData's `or [hl]` misses its `.error` arm + -- (engine/events/poke_seer.asm:105). + local pages = runSeer(unknown) + eq(pages[3], "The time was\nUnknown!\fIts level was ???!\fAm I good or what?", + "no time and no level print Unknown and ???") + + local hatched = seerMon({ caughtLevel = 1, caughtLocation = 1, caughtTime = 2, + level = 6 }) + local hatchedPages = runSeer(hatched) + check(hatchedPages[3]:find("level was 5", 1, true) ~= nil, + "CAUGHT_EGG_LEVEL prints EGG_LEVEL, not 1") +end + +-- SeerAdviceTexts (engine/events/poke_seer.asm:357-364), walked in order; +-- `sub c` is one byte, so the 255 row catches an underflow. +do + local function adviceFor(level, caught) + local mon = seerMon({ level = level, caughtLevel = caught, + caughtLocation = 1, caughtTime = 2 }) + return runSeer(mon)[4] + end + check(adviceFor(12, 10):find("little more care", 1, true) ~= nil, + "a 2 level gain is the 9 row") + check(adviceFor(35, 10):find("more confident", 1, true) ~= nil, + "25 is the 29 row") + check(adviceFor(50, 10):find("much strength", 1, true) ~= nil, + "40 is the 59 row") + check(adviceFor(70, 10):find("grown mighty", 1, true) ~= nil, + "60 is the 89 row") + check(adviceFor(95, 5):find("I'm impressed", 1, true) ~= nil, + "90 is the 100 row") + check(adviceFor(5, 10):find("little more care", 1, true) ~= nil, + "and `sub c` underflowing lands on the 255 row, which repeats the first") +end + +-- ================================================ UnusedFindItemInPCOrBag + +-- mobile/mobile_12_2.asm:194 wNumPCItems, then :201 wNumItems. +do + local save = newSave() + save.pcItems = { RARE_CANDY = 1 } + local vm = newVm(newHooks(save), 50) + run(vm, H.UnusedFindItemInPCOrBag) + eq(vm.scriptVar, 1, "an item in the PC answers TRUE") + + local vm2 = newVm(newHooks(save, { held = { [92] = true } }), 92) + run(vm2, H.UnusedFindItemInPCOrBag) + eq(vm2.scriptVar, 1, "an item in the pack answers TRUE too") + + local vm3 = newVm(newHooks(save), 92) + run(vm3, H.UnusedFindItemInPCOrBag) + eq(vm3.scriptVar, 0, "and neither is FALSE") +end + +-- ==================================================== the seam these ride + +do + for _, name in ipairs({ "MoveTutor", "BuenasPassword", "BuenaPrize", + "AskRememberPassword", "PokeSeer", "UnusedFindItemInPCOrBag" }) do + eq(Specials.HANDLER_SOURCE[name], "specials/crystal_extras.lua", + name .. " is owned by this module") + eq(Specials.STUBS[name], nil, name .. " is no longer a stub") + check(Specials.SUPERSEDED_STUBS[name] ~= nil, + "and its old stub reason was retired") + end + local ids = {} + for _, id in ipairs(require("src.ui.Screens").GEN2_IDS) do ids[id] = true end + check(ids.Gen2MoveTutor, "Gen2MoveTutor is a registered screen id") + check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too") +end + +S.finish() diff --git a/tests/gen2_crystal_gender_flag_test.lua b/tests/gen2_crystal_gender_flag_test.lua new file mode 100644 index 00000000..60ae6a12 --- /dev/null +++ b/tests/gen2_crystal_gender_flag_test.lua @@ -0,0 +1,144 @@ +-- ENGINE_PLAYER_IS_FEMALE, the Crystal engine flag `checkflag` asks at 14 map +-- sites, and the Crystal-only Surf refusal that shares the wiring. +-- luajit tests/gen2_crystal_gender_flag_test.lua +-- +-- Both come in a Crystal half and a Gold half: Gold declares no such flag and +-- keeps its documented "you can Surf on top of NPCs" bug (CT-10). +-- The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal gender flag") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local Save = require("src.core.gen2.Save") +local World = require("src.world.gen2.World") + +local priorVersion = GameVersion.get() + +-- ------------------------------------------------- the flag id, by name + +-- constants/engine_flags.asm's const block, with the badge rows at the ids a +-- real crystal cache reports and a hole where parse_const_block met a +-- const_skip. ENGINE_PLAYER_IS_FEMALE is row 100, so flag id 99. +local CRYSTAL_ORDER = {} +CRYSTAL_ORDER[27] = "ENGINE_ZEPHYRBADGE" +CRYSTAL_ORDER[28] = "ENGINE_HIVEBADGE" +CRYSTAL_ORDER[29] = "ENGINE_PLAINBADGE" +CRYSTAL_ORDER[30] = "ENGINE_FOGBADGE" +CRYSTAL_ORDER[31] = "ENGINE_MINERALBADGE" +CRYSTAL_ORDER[32] = "ENGINE_STORMBADGE" +CRYSTAL_ORDER[33] = "ENGINE_GLACIERBADGE" +CRYSTAL_ORDER[34] = "ENGINE_RISINGBADGE" +CRYSTAL_ORDER[35] = "ENGINE_BOULDERBADGE" +CRYSTAL_ORDER[42] = "ENGINE_EARTHBADGE" +-- the hole: nothing at 43..99, which is where ipairs would stop +CRYSTAL_ORDER[100] = "ENGINE_PLAYER_IS_FEMALE" + +FieldMoves.bindEngineFlags(CRYSTAL_ORDER) +eq(FieldMoves.FEMALE_FLAG, 99, "ENGINE_PLAYER_IS_FEMALE is flag 99 by NAME") +eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", + "and the badge block walks past the const_skip hole") +eq(FieldMoves.BADGE_FLAG[41].name, "EARTH", "including the last Kanto row") + +FieldMoves.bindEngineFlags(nil) +eq(FieldMoves.FEMALE_FLAG, nil, "Gold declares no such flag at all") +eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "and keeps its own badge ids") + +-- ------------------------------------------------- the flag, through World + +local function flagWorld(version, gender, order) + local game = { + data = {}, + save = { + version = version, + player = { name = "CHRIS", id = 1, gender = gender, badges = {} }, + engineFlags = {}, + }, + } + FieldMoves.bindEngineFlags(order) + local world = World.new(game) + world.constants = {} + return world, game +end + +do + local world, game = flagWorld("crystal", "female", CRYSTAL_ORDER) + check(world:engineFlag(99), "Kris answers checkflag ENGINE_PLAYER_IS_FEMALE") + check(not (game.save.engineFlags or {})[99], + "and it is NOT a second copy in save.engineFlags") + game.save.player.gender = "male" + check(not world:engineFlag(99), "Chris answers the same flag false") + -- the write half exists only so a stray setflag cannot open a second store + world:setEngineFlag(99, true) + eq(game.save.player.gender, "female", "setflag writes the gender byte") + check(not (game.save.engineFlags or {})[99], "still one store") + world:setEngineFlag(99, false) + eq(game.save.player.gender, "male", "and clearflag writes it back") +end + +do + local world, game = flagWorld("gold", "male", nil) + check(not world:engineFlag(99), "Gold's flag 99 is an ordinary flag") + world:setEngineFlag(99, true) + check(world:engineFlag(99), "which reads back out of save.engineFlags") + check((game.save.engineFlags or {})[99] == true, "where Gold keeps it") + eq(game.save.player.gender, "male", "and the gender byte is untouched") +end + +-- ------------------------------------------------- Save.isFemale, the seam + +check(Save.isFemale({ player = { gender = "female" } }), "isFemale reads Kris") +check(not Save.isFemale({ player = { gender = "male" } }), "and not Chris") +check(not Save.isFemale({ player = {} }), "a genderless save is Chris") +check(not Save.isFemale(nil), "and so is no save at all") + +-- ------------------------------------------------- Surf onto an NPC (CT-10) + +local WATER = 0x29 -- pokecrystal constants/collision_constants.asm + +local function surfCtx(facingObject) + return { + save = { player = { badges = { FOG = true } } }, + mon = { species = "LAPRAS" }, + facing = "down", + facingColl = WATER, + playerColl = 0x00, + playerState = FieldMoves.PLAYER_NORMAL, + facingObject = facingObject, + } +end + +GameVersion.set("crystal") +check(FieldMoves.surfFromMenu(surfCtx(nil)).ok, + "Crystal surfs onto open water") +check(not FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "and refuses onto a facing object (CheckFacingObject)") +eq(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).text, + FieldMoves.TEXT.CANT_SURF, "with .cannotsurf's own line") + +GameVersion.set("gold") +check(FieldMoves.surfFromMenu(surfCtx(nil)).ok, "Gold surfs onto open water") +check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "and KEEPS the cart's bug: Surf right on top of the NPC") + +GameVersion.set("silver") +check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "Silver keeps it too") + +-- TrySurfOW is byte-identical in both trees, so the OW path never gained the +-- check (pokecrystal engine/events/overworld.asm:484-514 vs pokegold :469-499). +GameVersion.set("crystal") +do + local ctx = surfCtx({ id = "npc" }) + ctx.party = { { species = "LAPRAS", moves = { { id = "SURF" } } } } + check(FieldMoves.trySurfOW(ctx).ok, + "the A-press path is unchanged by the menu fix, even on Crystal") +end + +GameVersion.set(priorVersion) +FieldMoves.bindEngineFlags(nil) +S.finish() diff --git a/tests/gen2_crystal_gender_test.lua b/tests/gen2_crystal_gender_test.lua new file mode 100644 index 00000000..868322cd --- /dev/null +++ b/tests/gen2_crystal_gender_test.lua @@ -0,0 +1,334 @@ +-- Crystal's player gender: the InitGender screen, the byte it writes, and the +-- eight places that read it back. +-- luajit tests/gen2_crystal_gender_test.lua +-- +-- Gold and Silver have no Kris to extract, so every assertion comes in a +-- Crystal half and a Gold half: Gold must still boot straight into Oak with no +-- gender beat in the speech and Chris on the field. +-- The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal gender") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local Pokegear = require("src.ui.gen2.Pokegear") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +local priorVersion = GameVersion.get() + +-- ------------------------------------------------- the two sprite tables + +-- data/sprites/player_sprites.asm +eq(FieldMoves.stateSprite("normal", "male"), "SPRITE_CHRIS", "Chris on foot") +eq(FieldMoves.stateSprite("bike", "male"), "SPRITE_CHRIS_BIKE", "Chris biking") +eq(FieldMoves.stateSprite("normal", "female"), "SPRITE_KRIS", "Kris on foot") +eq(FieldMoves.stateSprite("bike", "female"), "SPRITE_KRIS_BIKE", "Kris biking") +eq(FieldMoves.stateSprite("surf", "female"), "SPRITE_SURF", + "both share the Lapras") +eq(FieldMoves.stateSprite("surf_pika", "female"), "SPRITE_SURFING_PIKACHU", + "and the surfing PIKACHU") +eq(FieldMoves.stateSprite("normal", nil), "SPRITE_CHRIS", + "no recorded gender is PLAYERGENDER_MALE") +eq(FieldMoves.stateSprite("skate", "female"), "SPRITE_KRIS", + "a state with no row falls back to PLAYER_NORMAL's") +eq(FieldMoves.playerSprite("female"), "SPRITE_KRIS", "playerSprite is the same") +check(FieldMoves.isFemale("female"), "female reads as PLAYERGENDER_FEMALE_F") +check(not FieldMoves.isFemale("male"), "male does not") +check(not FieldMoves.isFemale(nil), "and neither does an absent byte") + +check(FieldMoves.hasGenderChoice({ SPRITE_KRIS = {} }), + "a cache with SPRITE_KRIS offers the choice") +check(not FieldMoves.hasGenderChoice({ SPRITE_CHRIS = {} }), + "a Gold cache does not") +check(not FieldMoves.hasGenderChoice(nil), "and neither does no cache at all") + +-- ------------------------------------------------- the save field (CT-5) + +eq(Save.defaultPlayerName("crystal", "male"), "CHRIS", "MalePlayerNameArray[0]") +eq(Save.defaultPlayerName("crystal", "female"), "KRIS", + "FemalePlayerNameArray[0]") +eq(Save.defaultPlayerName("gold", "female"), "GOLD", + "Gold has no female array to fall into") +eq(Save.defaultPlayerName("silver", "female"), "SILVER", "nor does Silver") +eq(Save.defaultPlayerName("crystal"), "CHRIS", "and no gender is Chris") + +check(Save.isFemale({ player = { gender = "female" } }), "isFemale on a save") +check(not Save.isFemale({ player = { gender = "male" } }), "and not on Chris") +check(not Save.isFemale({}), "nor on a save with no player block") + +GameVersion.set("crystal") +local kris = Save.newGame({ gender = "female" }) +eq(kris.player.gender, "female", "newGame records the answer") +eq(kris.player.name, "KRIS", "and lays down .Kris as the default name") +local chris = Save.newGame({}) +eq(chris.player.gender, "male", "InitCrystalData zeroes the byte") +eq(chris.player.name, "CHRIS", "so a fresh save is Chris") +eq(Save.newGame({ gender = "female", playerName = "ZZ" }).player.name, "ZZ", + "an explicit name still wins") + +local normalized = Save.normalize({ version = "crystal", + player = { gender = "female" } }) +eq(normalized.player.name, "KRIS", "normalize fills the gendered default") +eq(Save.normalize({ version = "crystal", player = {} }).player.name, "CHRIS", + "and the male one with no byte") + +-- ------------------------------------------------- the name presets + +GameVersion.set("crystal") +eq(NamePick.presetsFor("male")[1], "CHRIS", "ChrisNameMenuHeader row 1") +eq(NamePick.presetsFor("male")[4], "JON", "and row 4") +eq(NamePick.presetsFor("female")[1], "KRIS", "KrisNameMenuHeader row 1") +eq(NamePick.presetsFor("female")[4], "JODI", "and row 4") +eq(NamePick.presetsFor()[1], "CHRIS", "no gender is the male header") +GameVersion.set("gold") +eq(NamePick.presetsFor("female")[1], "GOLD", + "Gold keeps PlayerNameArray whatever is asked for") +GameVersion.set("silver") +eq(NamePick.presetsFor("female")[1], "SILVER", "and so does Silver") +GameVersion.set("crystal") + +eq(NamingScreen.playerSprite("female"), "SPRITE_KRIS", + "GetPlayerIcon's female sheet") +eq(NamingScreen.playerSprite("male"), "SPRITE_CHRIS", "and its male one") + +-- ------------------------------------------------- the screen itself + +check(Screens.GEN2_IDS ~= nil, "the registry lists the Gen 2 screens") +local registered = {} +for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end +check(registered.Gen2GenderSelect, "Gen2GenderSelect has an id") +eq(Screens.get({ data = {} }, "Gen2GenderSelect"), GenderSelect, + "and resolves to the builtin") + +eq(GenderSelect.OPTIONS[1].label, "Boy", ".MenuData item 1") +eq(GenderSelect.OPTIONS[1].gender, "male", "writes wPlayerGender 0") +eq(GenderSelect.OPTIONS[2].label, "Girl", ".MenuData item 2") +eq(GenderSelect.OPTIONS[2].gender, "female", "writes wPlayerGender 1") + +-- A joypad that answers one press then goes quiet, the way the harness fakes +-- input for every other screen suite here. +local function fakeInput(button) + local pressed = button + return { + wasPressed = function(_, name) + if pressed and name == pressed then + pressed = nil + return true + end + return false + end, + } +end + +local function screenGame(save, button) + return { save = save, data = {}, input = fakeInput(button) } +end + +local save = Save.newGame({}) +local answered +local game = screenGame(save, "a") +local screen = GenderSelect.new(game, { onDone = function(g) answered = g end }) +eq(screen.cursor, 1, "`db 1 ; default option` opens on Boy") +screen:update(0) +eq(save.player.gender, "male", "A on Boy writes PLAYERGENDER_MALE") +eq(answered, nil, "and the DelayFrames 10 has not run out yet") +for _ = 1, 10 do screen:update(0) end +eq(answered, "male", "onDone fires once the ten frames are gone") + +save = Save.newGame({}) +game = screenGame(save, "down") +screen = GenderSelect.new(game, { onDone = function() end }) +screen:update(0) +eq(screen.cursor, 2, "down walks to Girl") +game.input = fakeInput("down") +screen:update(0) +eq(screen.cursor, 1, "STATICMENU_WRAP wraps at the bottom") +game.input = fakeInput("up") +screen:update(0) +eq(screen.cursor, 2, "and at the top") +game.input = fakeInput("b") +screen:update(0) +eq(save.player.gender, "male", "STATICMENU_DISABLE_B: B answers nothing") +game.input = fakeInput("a") +screen:update(0) +eq(save.player.gender, "female", "A on Girl writes PLAYERGENDER_FEMALE") + +check(type(screen.text) == "string" and screen.text:find("boy"), + "the prompt is _AreYouABoyOrAreYouAGirlText") + +-- ------------------------------------------------- the beat in Oak's speech + +local function speechFor(sprites) + return { game = { data = { gen2Sprites = sprites } } } +end + +local crystalSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_KRIS = {} })) +eq(crystalSteps[1].id, "gender_select", + "PlayerProfileSetup's InitGender runs before OakSpeech") +eq(crystalSteps[1].kind, "gender", "as its own step kind") +eq(crystalSteps[2].id, "init_clock", "and the clock follows it") + +local goldSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_CHRIS = {} })) +eq(goldSteps[1].id, "init_clock", "Gold opens on the clock, as it always did") +for _, step in ipairs(goldSteps) do + check(step.kind ~= "gender", "and never grows a gender beat") +end +eq(OakSpeech.defaultSteps()[1].id, "init_clock", + "a bare call (no speech) is the Gold list") +eq(#goldSteps + 1, #crystalSteps, "the Crystal list is the Gold list plus one") + +-- ------------------------------------------------- the trainer card + +local CARD_GFX = { + card = "card.png", cardFemale = "card_f.png", cardTilesWide = 16, + leaderClasses = { "FALKNER" }, + -- the male attrmap the extractor writes: portrait on $0, corner on $1 + paletteZones = { { 14, 1, 5, 7, 1 }, { 18, 1, 1, 1, 2 } }, +} + +local function cardFor(gender) + return TrainerCard.new({ data = {} }, { + save = { player = { gender = gender, badges = {}, kantoBadges = {} } }, + menuGfx = { trainerCard = CARD_GFX }, + palettes = { trainers = { PLAYER = { { 1, 1, 1 }, { 2, 2, 2 } }, + FALKNER = { { 3, 3, 3 }, { 4, 4, 4 } } } }, + }) +end + +local male = cardFor("male") +check(not male.female, "Chris keeps ChrisCardPic") +eq(male.zoneDefault, 2, "and the border wears Falkner's row") +eq(male.zone[1 * 20 + 14], 1, "with the portrait on the player's") + +local female = cardFor("female") +check(female.female, "Kris takes KrisCardPic") +eq(female.zoneDefault, 1, "the border swaps to Chris's row") +eq(female.zone[1 * 20 + 14], 2, "the portrait to Falkner's, which is Kris's") +eq(female.zone[1 * 20 + 18], 1, "the top-right corner follows the border") +eq(female.zone[14 * 20 + 14], 2, "and Clair borrows Kris's palette") +eq(female.zone[14 * 20 + 10], nil, "Pryce's box is untouched") + +local noFemaleArt = TrainerCard.new({ data = {} }, { + save = { player = { gender = "female" } }, + menuGfx = { trainerCard = { card = "card.png", paletteZones = {} } }, +}) +check(not noFemaleArt.female, + "a Gold cache with no cardFemale draws the card it has") + +-- ------------------------------------------------- the Pokegear + +local GEAR_GFX = { + tiles = "gear.png", + palettes = { { { 1, 1, 1 } } }, + palettesFemale = { { { 9, 9, 9 } } }, +} + +local function gearFor(gender) + return Pokegear.new({ data = {} }, { + save = { player = { gender = gender }, pokegearFlags = {} }, + menuGfx = { pokegear = GEAR_GFX }, + }) +end + +eq(gearFor("male"):pals(), GEAR_GFX.palettes, "MalePokegearPals for Chris") +eq(gearFor("female"):pals(), GEAR_GFX.palettesFemale, + "FemalePokegearPals for Kris") +local noFemalePals = Pokegear.new({ data = {} }, { + save = { player = { gender = "female" }, pokegearFlags = {} }, + menuGfx = { pokegear = { tiles = "gear.png", palettes = GEAR_GFX.palettes } }, +}) +eq(noFemalePals:pals(), GEAR_GFX.palettes, + "a Gold cache has one set and uses it") + +-- ------------------------------------------------- the magnet train + +local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide") +local SPRITES = { SPRITE_CHRIS = { image = "chris.png" }, + SPRITE_KRIS = { image = "kris.png" } } +eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES }, + gender = "female" }), SPRITES.SPRITE_KRIS, "Kris rides MAGNET_TRAIN_BLUE") +eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES }, + gender = "male" }), SPRITES.SPRITE_CHRIS, "Chris rides MAGNET_TRAIN_RED") +eq(MagnetTrainRide.playerSpriteDef({ + data = { gen2Sprites = { SPRITE_CHRIS = SPRITES.SPRITE_CHRIS } }, + gender = "female" }), SPRITES.SPRITE_CHRIS, + "a Gold cache has only the one sheet") + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local function exists(rel) + local f = io.open(cache .. "/" .. rel, "rb") + if not f then return false end + f:close() + return true +end + +local sprites = loadLua("data/generated/sprites.lua") +if not sprites then + check(true, "crystal cache absent : SKIP") + GameVersion.set(priorVersion) + S.finish() + return +end + +check(sprites.SPRITE_KRIS ~= nil, "the cache carries SPRITE_KRIS") +check(sprites.SPRITE_KRIS_BIKE ~= nil, "and SPRITE_KRIS_BIKE") +eq(sprites.SPRITE_KRIS.palette, "PAL_OW_BLUE", "Kris is PAL_NPC_BLUE") +eq(sprites.SPRITE_CHRIS.palette, "PAL_OW_RED", "Chris is PAL_NPC_RED") +check(FieldMoves.hasGenderChoice(sprites), + "so a real Crystal cache offers the choice") + +for _, rel in ipairs({ + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", + "assets/generated/trainer_card/card.png", + "assets/generated/trainer_card/card_f.png", + "assets/generated/sprites/kris.png", + "assets/generated/sprites/kris_bike.png", +}) do + check(exists(rel), "the cache carries " .. rel) +end + +local menuGfx = loadLua("data/generated/menu_gfx.lua") or {} +check(menuGfx.trainerCard and menuGfx.trainerCard.cardFemale ~= nil, + "menu_gfx.trainerCard.cardFemale is extracted") +check(menuGfx.pokegear and menuGfx.pokegear.palettesFemale ~= nil, + "menu_gfx.pokegear.palettesFemale is extracted") +check(menuGfx.pack and menuGfx.pack.palettesFemale ~= nil, + "menu_gfx.pack.palettesFemale is extracted") + +local palettes = loadLua("data/generated/palettes.lua") or {} +check(palettes.trainers and palettes.trainers.PLAYER ~= nil, + "PlayerPalette is row 0") +check(palettes.trainers and palettes.trainers.FALKNER ~= nil, + "and KrisPalette is Falkner's") + +local romText = loadLua("data/generated/rom_text.lua") or {} +check(romText._AreYouABoyOrAreYouAGirlText ~= nil, + "the prompt is in the extracted text") + +GameVersion.set(priorVersion) +S.finish() diff --git a/tests/gen2_crystal_mobile_gfx_test.lua b/tests/gen2_crystal_mobile_gfx_test.lua new file mode 100644 index 00000000..f4cf94d3 --- /dev/null +++ b/tests/gen2_crystal_mobile_gfx_test.lua @@ -0,0 +1,463 @@ +-- Crystal's Kris battle/Hall-of-Fame art and the Mobile System GB sheets. +-- luajit tests/gen2_crystal_mobile_gfx_test.lua +-- +-- Two imports that had no consumer at the time they landed, so nothing else +-- would notice them rotting: Kris's backpic and KRIS trainer-class pic (wave A +-- shipped the female protagonist but battles and the Hall of Fame still drew +-- Chris), and the whole gfx/mobile + gfx/mystery_gift set, imported ahead of +-- the phase that uses it so Crystal never has to re-import for it. +-- +-- What is held down: the stage exists and run() calls it, it is gated on the +-- crystal edition so Gold and Silver emit nothing, every label it reads is in +-- the generated Crystal manifest at the address pokecrystal.sym gives, and the +-- blobs it wrote are ../pokecrystal/gfx/mobile byte for byte. +-- +-- ROM-free. The decomp half SKIPs with no ../pokecrystal beside the repo, the +-- cache half with no Crystal cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal mobile gfx") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +-- ------------------------------------------------------- the stage itself + +local extractorSource = assert(readFile("src/import/RomExtractorGen2.lua")) +local RomExtractorGen2 = require("src.import.RomExtractorGen2") + +check(type(RomExtractorGen2.extractMobileGfx) == "function", + "RomExtractorGen2:extractMobileGfx exists") +check(extractorSource:find("results.mobileGfx = self:extractMobileGfx()", + 1, true) ~= nil, "and RomExtractorGen2:run calls it") +check(extractorSource:find( + 'if self.edition ~= "crystal" then return nil end', 1, true) ~= nil, + "gated on the edition, so Gold and Silver write no mobile art") +-- The two suites that pin the progress bar read this same literal; a new +-- stage must not move it (tests/gen2_diploma_test.lua:45). +check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil, + "and it did not disturb STAGE_COUNT") + +-- ------------------------------------------------------------ the symbols + +-- ../pokecrystal-symbols/pokecrystal.sym, international v1.0. Every one of +-- these resolves in that build: the Japanese-only part of Mobile System GB is +-- the text and the menu entries that reach it, not the art. +local SYMBOLS = { + AsciiFontGFX = { 0x5c, 0x5db1 }, + PichuAnimatedMobileGFX = { 0x5c, 0x4d16 }, + ElectroBallMobileGFX = { 0x5c, 0x55a4 }, + PichuBorderMobileGFX = { 0x5c, 0x5848 }, + Stadium2N64GFX = { 0x5c, 0x6f1f }, + Stadium2N64Tilemap = { 0x5c, 0x73af }, + Stadium2N64Attrmap = { 0x5c, 0x7517 }, + PasswordTopTilemap = { 0x5c, 0x6491 }, + PasswordBottomTilemap = { 0x5c, 0x651d }, + PasswordShiftTilemap = { 0x5c, 0x65f9 }, + ChooseMobileCenterTilemap = { 0x5c, 0x6685 }, + MobilePasswordAttrmap = { 0x5c, 0x67ed }, + ChooseMobileCenterAttrmap = { 0x5c, 0x6955 }, + MobilePasswordPalettes = { 0x5c, 0x5d71 }, + MobileCardGFX = { 0x5e, 0x59ef }, + ChrisSilhouetteGFX = { 0x5e, 0x5bef }, + KrisSilhouetteGFX = { 0x5e, 0x5e1f }, + MobileCard2GFX = { 0x5e, 0x604f }, + CardLargeSpriteAndFolderGFX = { 0x5e, 0x61bf }, + CardSpriteGFX = { 0x5e, 0x664f }, + DialpadTilemap = { 0x5e, 0x6cd5 }, + DialpadAttrmap = { 0x5e, 0x6e3d }, + DialpadGFX = { 0x5e, 0x6fa5 }, + DialpadCursorGFX = { 0x5e, 0x7465 }, + MobileCardListGFX = { 0x5e, 0x74b9 }, + HaveWantGFX = { 0x5f, 0x4083 }, + MobileSelectGFX = { 0x5f, 0x4983 }, + HaveWantMap = { 0x5f, 0x4b83 }, + PokemonNewsGFX = { 0x5f, 0x66fe }, + PokemonNewsTileAttrmap = { 0x5f, 0x6b8e }, + PokemonNewsPalettes = { 0x5f, 0x6ff6 }, + ["MobileSystemSplashScreen_InitGFX.Tiles"] = { 0x5b, 0x4173 }, + ["MobileSystemSplashScreen_InitGFX.Tilemap"] = { 0x5b, 0x4633 }, + ["MobileSystemSplashScreen_InitGFX.Attrmap"] = { 0x5b, 0x479b }, + MobileSplashScreenPalettes = { 0x5b, 0x4903 }, + MobileAdapterCheckGFX = { 0x5b, 0x4ca3 }, + MobileTradeSpritesGFX = { 0x42, 0x4d27 }, + MobileTradeGFX = { 0x42, 0x4da7 }, + MobileTradeTilemapLZ = { 0x42, 0x4fe7 }, + MobileTradeAttrmapLZ = { 0x42, 0x50a7 }, + MobileCable1GFX = { 0x42, 0x51c7 }, + MobileCable2GFX = { 0x42, 0x52c7 }, + UnusedMobilePulsePalettes = { 0x42, 0x50f7 }, + MobileTradeBGPalettes = { 0x42, 0x5107 }, + MobileTradeOB1Palettes = { 0x42, 0x5147 }, + MobileTradeOB2Palettes = { 0x42, 0x5187 }, + MobileAdapterPalettes = { 0x42, 0x53c7 }, + MobileTradeLightsGFX = { 0x40, 0x72a2 }, + MobileTradeLightsPalettes = { 0x40, 0x72e2 }, + PichuBorderMobileOBPalettes = { 0x45, 0x730e }, + PichuBorderMobileBGPalettes = { 0x45, 0x734e }, + PichuBorderMobileTilemapAttrmap = { 0x45, 0x7356 }, + MobileDialingGFX = { 0x45, 0x601a }, + MobileUpArrowGFX = { 0x12, 0x48c3 }, + MobileDownArrowGFX = { 0x12, 0x48cb }, + EZChatCursorGFX = { 0x22, 0x540b }, + MobileDialingFrameGFX = { 0x41, 0x6514 }, + SelectStartGFX = { 0x47, 0x567e }, + MobileMenuGFX = { 0x12, 0x5c0c }, + MobilePhoneTilesGFX = { 0x3e, 0x5234 }, + MysteryGiftGFX = { 0x41, 0x5258 }, + CardTradeGFX = { 0x41, 0x5930 }, + CardTradeSpriteGFX = { 0x41, 0x5d30 }, +} + +-- Kris's own two pics. KrisBackpic needs no delta row: embedded_symbols in +-- make_gold_manifest.py adds every unqualified *Backpic label on sight. +local KRIS_SYMBOLS = { + KrisBackpic = { 0x22, 0x4ed6 }, + KrisPic = { 0x22, 0x4bb9 }, + ChrisPic = { 0x22, 0x48a9 }, +} + +do + local deltas = assert(readFile("tools/crystal_symbol_deltas.py")) + for label in pairs(SYMBOLS) do + check(deltas:find('"' .. label .. '"', 1, true) ~= nil, + label .. " is in crystal_symbol_deltas.MOBILE_SYMBOLS") + end +end + +local Json = require("src.link.Json") +local function manifestSymbols(path) + local body = readFile(path) + if not body then return nil end + return (Json.decode(body) or {}).symbols or {} +end + +do + local symbols = manifestSymbols("tools/rom_manifest_crystal.json") + if not symbols then + check(true, "no tools/rom_manifest_crystal.json (SKIP)") + else + local wrong = {} + for label, at in pairs(SYMBOLS) do + local got = symbols[label] + if not got or got[1] ~= at[1] or got[2] ~= at[2] then + wrong[#wrong + 1] = label + end + end + check(#wrong == 0, #wrong == 0 + and "the Crystal manifest carries all 63 mobile symbols where " + .. "pokecrystal.sym puts them" + or ("mobile symbols missing or misplaced: " .. table.concat(wrong, ", "))) + for label, at in pairs(KRIS_SYMBOLS) do + local got = symbols[label] + if not got then + check(false, "the Crystal manifest carries " .. label) + else + eq(got[1], at[1], label .. " is in the bank pokecrystal.sym says") + eq(got[2], at[2], label .. " is at the address it says") + end + end + end +end + +do + -- Gold has no mobile banks; a delta that leaked into the shared set would + -- break the Gold import rather than show up here as a missing sheet. + local symbols = manifestSymbols("tools/rom_manifest_gold.json") + if not symbols then + check(true, "no tools/rom_manifest_gold.json (SKIP)") + else + local leaked = {} + for label in pairs(SYMBOLS) do + if symbols[label] then leaked[#leaked + 1] = label end + end + check(#leaked == 0, #leaked == 0 + and "and the Gold manifest carries none of them" + or ("mobile symbols leaked into Gold: " .. table.concat(leaked, ", "))) + check(symbols.KrisBackpic == nil, "nor Gold's non-existent KrisBackpic") + end +end + +-- --------------------------------------------------------------- the cache + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local function loadCache(rel) + local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua") + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +-- PNG IHDR: width and height are big-endian at bytes 17 and 21. +local function pngSize(relative) + local png = readFile(cache .. "/" .. relative) + if not png then return nil end + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be32(png, 17), be32(png, 21) +end + +-- ../pokecrystal/gfx/player/kris_back.png is 48x48 and kris.png 56x56, the +-- same six and seven tiles square Chris uses. +local BACK_PIC_PX = 48 +local TRAINER_PIC_PX = 56 + +-- Kris's art and the mobile sheets ride one import, so mobile_gfx.lua doubles +-- as the marker for both: a cache built before this unit has neither, and the +-- whole cache half stands down rather than failing on an old sandbox. +local mobile = loadCache("mobile_gfx") +local menuGfx = mobile and loadCache("menu_gfx") or nil +if not menuGfx then + check(true, "no Crystal cache with mobile_gfx.lua at " .. cache + .. " : re-import for gfx/mobile (SKIP)") +else + local hud = menuGfx.battleHud or {} + eq(hud.playerBackFemale, "assets/generated/battle/player_back_female.png", + "menu_gfx points battles and the Hall of Fame at Kris's backpic") + local w, h = pngSize("assets/generated/battle/player_back_female.png") + eq(w, BACK_PIC_PX, "GetKrisBackpic's pic is 48px wide") + eq(h, BACK_PIC_PX, "and 48 tall, not the 7*7 tiles of VRAM it asks for") + check(hud.playerBack ~= hud.playerBackFemale, + "and it is not the same file Chris gets") + + local pics = hud.trainerPics or {} + eq(pics.KRIS, "assets/generated/battle/trainers/kris.png", + "HOF_LoadTrainerFrontpic's KRIS class has a pic of its own") + eq(pics.CHRIS, "assets/generated/battle/trainers/chris.png", + "as does CHRIS, which TrainerPicPointers has no row for") + for _, key in ipairs({ "kris", "chris" }) do + local pw, ph = pngSize("assets/generated/battle/trainers/" .. key .. ".png") + eq(pw, TRAINER_PIC_PX, key .. ".png is 56px wide") + eq(ph, TRAINER_PIC_PX, "and 56 tall, like every other trainer pic") + end +end + +local oakSpeech = mobile and loadCache("oak_speech") or nil +if not oakSpeech then + check(true, "no oak_speech.lua beside a mobile_gfx.lua cache (SKIP)") +else + eq(oakSpeech.playerPicFemale, "assets/generated/intro/kris.png", + "and DrawIntroPlayerPic's female arm is named in oak_speech.lua") +end + +-- Sheets whose tile count fills the pret PNG's grid exactly once padded, so +-- the cache PNG must come out at the source PNG's own size. The three left +-- out (mobile_splash, electro_ball, phone_tiles) are built with +-- --remove-duplicates / --remove-whitespace, so the ROM holds fewer tiles than +-- the PNG does and only the tilemap puts them back (../pokecrystal/Makefile: +-- 343,344,348). +local SHEETS = { + { "asciiFont", "mobile/ascii_font.png", 110, 16, "gfx/mobile/ascii_font.png" }, + { "card", "mobile/card.png", 32, 16, "gfx/mobile/card.png" }, + { "card2", "mobile/card_2.png", 23, 12, "gfx/mobile/card_2.png" }, + { "cardLargeSprite", "mobile/card_large_sprite.png", 8, 4, + "gfx/mobile/card_large_sprite.png" }, + { "cardFolder", "mobile/card_folder.png", 65, 6, + "gfx/mobile/card_folder.png" }, + { "cardList", "mobile/card_list.png", 24, 12, "gfx/mobile/card_list.png" }, + { "cardSprite", "mobile/card_sprite.png", 4, 2, "gfx/mobile/card_sprite.png" }, + { "chrisSilhouette", "mobile/chris_silhouette.png", 35, 5, + "gfx/mobile/chris_silhouette.png" }, + { "krisSilhouette", "mobile/kris_silhouette.png", 35, 5, + "gfx/mobile/kris_silhouette.png" }, + { "dialing", "mobile/dialing.png", 20, 2, "gfx/mobile/dialing.png" }, + { "dialingFrame", "mobile/dialing_frame.png", 8, 2, + "gfx/mobile/dialing_frame.png" }, + { "dialpad", "mobile/dialpad.png", 76, 16, "gfx/mobile/dialpad.png" }, + { "dialpadCursor", "mobile/dialpad_cursor.png", 5, 2, + "gfx/mobile/dialpad_cursor.png" }, + { "ezChatCursor", "mobile/ez_chat_cursor.png", 2, 1, + "gfx/mobile/ez_chat_cursor.png" }, + { "haveWant", "mobile/havewant.png", 144, 16, "gfx/mobile/havewant.png" }, + { "select", "mobile/select.png", 32, 4, "gfx/mobile/select.png" }, + { "selectStart", "mobile/select_start.png", 6, 3, + "gfx/mobile/select_start.png" }, + { "cable1", "mobile/mobile_cable_1.png", 16, 4, + "gfx/mobile/mobile_cable_1.png" }, + { "cable2", "mobile/mobile_cable_2.png", 16, 4, + "gfx/mobile/mobile_cable_2.png" }, + { "menu", "mobile/mobile_menu.png", 13, 13, "gfx/mobile/mobile_menu.png" }, + { "adapterCheck", "mobile/mobile_splash_check.png", 48, 16, + "gfx/mobile/mobile_splash_check.png" }, + { "tradeLights", "mobile/mobile_trade_lights.png", 4, 2, + "gfx/mobile/mobile_trade_lights.png" }, + { "pichuBorder", "mobile/pichu_border.png", 24, 4, + "gfx/mobile/pichu_border.png" }, + { "pokemonNews", "mobile/pokemon_news.png", 72, 8, + "gfx/mobile/pokemon_news.png" }, + { "stadium2N64", "mobile/stadium2_n64.png", 73, 11, + "gfx/mobile/stadium2_n64.png" }, + { "pichuAnimated", "mobile/pichu_animated.png", 193, 16, + "gfx/mobile/pichu_animated.png" }, + { "trade", "mobile/mobile_trade.png", 128, 8, "gfx/mobile/mobile_trade.png" }, + { "tradeSprites", "mobile/mobile_trade_sprites.png", 16, 4, + "gfx/mobile/mobile_trade_sprites.png" }, + { "upArrow", "mobile/up_arrow.png", 1, 1, "gfx/mobile/up_arrow.png" }, + { "downArrow", "mobile/down_arrow.png", 1, 1, "gfx/mobile/down_arrow.png" }, + { "mysteryGift", "mobile/mystery_gift/mystery_gift.png", 67, 16, + "gfx/mystery_gift/mystery_gift.png" }, + { "cardTrade", "mobile/mystery_gift/card_trade.png", 64, 16, + "gfx/mystery_gift/card_trade.png" }, + { "cardTradeSprite", "mobile/mystery_gift/card_sprite.png", 8, 4, + "gfx/mystery_gift/card_sprite.png" }, +} + +-- The three the dedupe passes shrink: only the declared tile count is checked. +local DEDUPED = { + { "splash", "mobile/mobile_splash.png", 76, 14 }, + { "electroBall", "mobile/electro_ball.png", 83, 16 }, + { "phoneTiles", "mobile/phone_tiles.png", 17, 2 }, +} + +-- key, cache file, byte length, ../pokecrystal source it must equal. +local MAPS = { + { "passwordTop", "password_top", 140, "gfx/mobile/password_top.tilemap" }, + { "passwordBottom", "password_bottom", 220, + "gfx/mobile/password_bottom.tilemap" }, + { "passwordShift", "password_shift", 140, + "gfx/mobile/password_shift.tilemap" }, + { "passwordAttrmap", "password_attrmap", 360, + "gfx/mobile/password.attrmap" }, + { "centerTilemap", "mobile_center_tilemap", 360, + "gfx/mobile/mobile_center.tilemap" }, + { "centerAttrmap", "mobile_center_attrmap", 360, + "gfx/mobile/mobile_center.attrmap" }, + { "stadium2N64Tilemap", "stadium2_n64_tilemap", 360, + "gfx/mobile/stadium2_n64.tilemap" }, + { "stadium2N64Attrmap", "stadium2_n64_attrmap", 360, + "gfx/mobile/stadium2_n64.attrmap" }, + { "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" }, + { "dialpadAttrmap", "dialpad_attrmap", 360, "gfx/mobile/dialpad.attrmap" }, + { "haveWantMap", "havewant_map", 1136, "gfx/mobile/havewant_map.bin" }, + { "newsAttrmap", "pokemon_news_attrmap", 1128, + "gfx/mobile/pokemon_news.bin" }, + { "splashTilemap", "mobile_splash_tilemap", 360, + "gfx/mobile/mobile_splash.tilemap" }, + { "splashAttrmap", "mobile_splash_attrmap", 360, + "gfx/mobile/mobile_splash.attrmap" }, + { "pichuBorderTilemap", "pichu_border_tilemap", 384, + "gfx/mobile/pichu_border.tilemap" }, + { "pichuBorderAttrmap", "pichu_border_attrmap", 384, + "gfx/mobile/pichu_border.attrmap" }, + { "tradeTilemap", "mobile_trade_tilemap", 1024, + "gfx/mobile/mobile_trade.tilemap" }, + { "tradeAttrmap", "mobile_trade_attrmap", 1024, + "gfx/mobile/mobile_trade.attrmap" }, +} + +local PALETTE_COLORS = { + splash = 32, password = 32, pokemonNews = 32, pichuBorderOB = 32, + pichuBorderBG = 4, tradeBG = 32, tradeOB1 = 32, tradeOB2 = 32, + tradeLights = 16, adapters = 8, unusedPulses = 8, +} + +local pokecrystal = "../pokecrystal" +local havePret = readFile(pokecrystal .. "/gfx/mobile/ascii_font.2bpp") ~= nil + +if not mobile then + check(true, "no mobile_gfx.lua in the Crystal cache (SKIP)") +else + eq(mobile.generation, 2, "mobile_gfx.lua is a Gen 2 table") + local sheets = mobile.sheets or {} + local maps = mobile.maps or {} + local palettes = mobile.palettes or {} + + local function checkSheet(row, pretPng) + local key, rel, tiles, wide = row[1], row[2], row[3], row[4] + local entry = sheets[key] + if type(entry) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " sheet") + return + end + eq(entry.tiles, tiles, key .. " is the tile count the ROM block holds") + eq(entry.tilesWide, wide, "and is written " .. wide .. " tiles across") + eq(entry.path, "assets/generated/" .. rel, "at the path it advertises") + local w, h = pngSize("assets/generated/" .. rel) + eq(w, wide * 8, key .. ".png is " .. (wide * 8) .. "px wide") + eq(h, math.ceil(tiles / wide) * 8, "and tall enough for every tile") + if pretPng and havePret then + local png = readFile(pokecrystal .. "/" .. pretPng) + if png then + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + eq(w, be32(png, 17), "which is " .. pretPng .. "'s own width") + eq(h, be32(png, 21), "and its own height") + end + end + end + + for _, row in ipairs(SHEETS) do checkSheet(row, row[5]) end + for _, row in ipairs(DEDUPED) do checkSheet(row, nil) end + + for _, row in ipairs(MAPS) do + local key, file, bytes, source = row[1], row[2], row[3], row[4] + local entry = maps[key] + local rel = "assets/generated/mobile/" .. file .. ".bin" + if type(entry) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " map") + else + eq(entry.bytes, bytes, key .. " is " .. bytes .. " bytes") + eq(entry.path, rel, "at the path it advertises") + local blob = readFile(cache .. "/" .. rel) + if not blob then + check(false, rel .. " was written") + else + eq(#blob, bytes, "and the file on disk is that long") + local want = havePret and readFile(pokecrystal .. "/" .. source) or nil + if not want then + check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)") + else + check(blob == want:sub(1, bytes), + "it is ../pokecrystal/" .. source .. " byte for byte") + end + end + end + end + + for key, colors in pairs(PALETTE_COLORS) do + local pal = palettes[key] + if type(pal) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " palette block") + else + eq(#pal, colors, key .. " is " .. colors .. " RGB words") + eq(#(pal[1] or {}), 3, "each one decoded to an r,g,b triple") + end + end +end + +-- A Gold cache must have gone nowhere near any of this. +local goldCache = os.getenv("GOLD_CACHE") +if not goldCache then + local home = os.getenv("HOME") or "" + goldCache = home .. "/Library/Application Support/LOVE/gold-dev/gold" +end +if not loadfile(goldCache .. "/data/generated/menu_gfx.lua") then + check(true, "no Gold cache at " .. goldCache .. " (SKIP)") +else + check(loadfile(goldCache .. "/data/generated/mobile_gfx.lua") == nil, + "a Gold cache has no mobile_gfx.lua") + local chunk = loadfile(goldCache .. "/data/generated/menu_gfx.lua") + local ok, gold = pcall(chunk) + local hud = (ok and gold or {}).battleHud or {} + check(hud.playerBackFemale == nil, "and no Kris backpic") + check((hud.trainerPics or {}).KRIS == nil, "and no KRIS trainer pic") +end + +S.finish() diff --git a/tests/gen2_crystal_story_test.lua b/tests/gen2_crystal_story_test.lua new file mode 100644 index 00000000..0ac3267e --- /dev/null +++ b/tests/gen2_crystal_story_test.lua @@ -0,0 +1,555 @@ +-- The Crystal story specials -- BeastsCheck, GiveDratini, GiveOddEgg and the +-- Ilex Forest shrine pair -- plus the roamer roster difference that makes the +-- Tin Tower Suicune catchable. +-- Self-contained: `luajit tests/gen2_crystal_story_test.lua`; also dofile'd by +-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal story") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local Roamers = require("src.core.gen2.Roamers") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +local H = Specials.HANDLERS +local priorVersion = GameVersion.get() +local priorRandom = Specials.random + +-- ---- fixtures ------------------------------------------------------------- + +-- pokecrystal/data/pokemon/base_stats/*.asm, the seven Odd Egg species plus +-- the Dragon Shrine's Dratini. +local BASE = { + PICHU = { 20, 40, 15, 60, 35, 35, "GROWTH_MEDIUM_FAST" }, + CLEFFA = { 50, 25, 28, 15, 45, 55, "GROWTH_FAST" }, + IGGLYBUFF = { 90, 30, 15, 15, 40, 20, "GROWTH_FAST" }, + SMOOCHUM = { 45, 30, 15, 65, 85, 65, "GROWTH_MEDIUM_FAST" }, + MAGBY = { 45, 75, 37, 83, 70, 55, "GROWTH_MEDIUM_FAST" }, + ELEKID = { 45, 63, 37, 95, 65, 55, "GROWTH_MEDIUM_FAST" }, + TYROGUE = { 35, 35, 35, 35, 35, 35, "GROWTH_MEDIUM_FAST" }, + DRATINI = { 41, 64, 45, 50, 50, 50, "GROWTH_SLOW" }, + RAIKOU = { 90, 85, 75, 115, 115, 100, "GROWTH_SLOW" }, + ENTEI = { 115, 115, 85, 100, 90, 75, "GROWTH_SLOW" }, + SUICUNE = { 100, 75, 115, 85, 90, 115, "GROWTH_SLOW" }, +} + +-- pokecrystal/data/moves/moves.asm, the PP byte of every move these two +-- routines hand out. +local MOVE_PP = { + THUNDERSHOCK = 30, CHARM = 20, DIZZY_PUNCH = 10, POUND = 35, SING = 15, + LICK = 30, EMBER = 25, QUICK_ATTACK = 30, LEER = 30, TACKLE = 35, + WRAP = 20, THUNDER_WAVE = 20, TWISTER = 20, EXTREMESPEED = 5, +} + +-- pokecrystal/data/growth_rates.asm MEDIUM_FAST, FAST and SLOW. +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + GROWTH_FAST = { numerator = 4, denominator = 5, squared = 0, linear = 0, + constant = 0 }, + GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0, + constant = 0 }, +} + +local INDEX = { PICHU = 172, CLEFFA = 173, IGGLYBUFF = 174, SMOOCHUM = 238, + MAGBY = 240, ELEKID = 239, TYROGUE = 236, DRATINI = 147, RAIKOU = 243, + ENTEI = 244, SUICUNE = 245 } + +local DATA = { pokemon = { growthRates = GROWTH }, moves = {} } +for id, row in pairs(BASE) do + DATA.pokemon[id] = { + id = id, index = INDEX[id], name = id, + baseStats = { hp = row[1], attack = row[2], defense = row[3], + speed = row[4], specialAttack = row[5], specialDefense = row[6] }, + types = { "NORMAL", "NORMAL" }, + growthRate = row[7], + genderRatio = 127, + eggGroups = { "EGG_GROUND", "EGG_GROUND" }, + eggSteps = 20, + evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + } +end +for id, pp in pairs(MOVE_PP) do DATA.moves[id] = { id = id, pp = pp } end + +local ITEM_INDEX = { EGG_TICKET = 129, GS_BALL = 115 } + +-- The World hook table a handler sees, with only the rows these five need. +local function makeVm(opts) + opts = opts or {} + local record = opts.save + local tossed = {} + local vars = opts.vars or {} + local vm = { + scriptVar = opts.scriptVar or 0, + battleOutcome = opts.battleOutcome, + tossed = tossed, + vars = vars, + readVarFn = function(id) return vars[id] or 0 end, + writeVarFn = function(id, value) vars[id] = value end, + specials = { + save = function() return record end, + data = function() return DATA end, + party = function() return (record and record.party) or {} end, + monIndex = function(species) + local def = DATA.pokemon[species] + return def and def.index or nil + end, + monName = function(index) + for id, def in pairs(DATA.pokemon) do + if def.index == index then return def.name end + end + return nil + end, + itemIndex = function(id) return ITEM_INDEX[id] end, + takeItem = function(index, qty) + tossed[#tossed + 1] = { index = index, qty = qty } + return true + end, + }, + } + return vm +end + +local function newSave() + return { version = "crystal", player = { name = "CHRIS", id = 30000 }, + party = {}, boxes = {} } +end + +-- ---- BeastsCheck ---------------------------------------------------------- +-- pokecrystal/engine/pokemon/search_owned.asm:1 + +do + local record = newSave() + local function beast(species, opts) + opts = opts or {} + return { species = species, level = 40, + ot = opts.ot or "CHRIS", otId = opts.otId or 30000 } + end + + local vm = makeVm({ save = record }) + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "BeastsCheck is FALSE with an empty party") + + record.party = { beast("RAIKOU"), beast("ENTEI") } + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "two of three is still FALSE") + + -- CheckOwnMonAnywhere walks the PC after the party (:80 onward). + record.boxes = { box1 = { beast("SUICUNE") } } + H.BeastsCheck(vm) + eq(vm.scriptVar, 1, "the third one counts from a PC box") + + record.boxes.box1[1].otId = 9999 + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "a traded beast fails the trainer-ID check") + + record.boxes.box1[1].otId = 30000 + record.boxes.box1[1].ot = "ETHAN" + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "and a traded beast fails the OT-name check") + + record.boxes.box1[1].ot = "CHRIS" + H.BeastsCheck(vm) + eq(vm.scriptVar, 1, "all three owned answers TRUE") + eq(vm.scriptVar, 1, "and re-running it is stable") +end + +-- ---- GiveDratini ---------------------------------------------------------- +-- pokecrystal/engine/events/dratini.asm:1 + +local function dratini(nickname) + local mon = Mon.new(DATA, "DRATINI", 15, {}) + mon.nickname = nickname + mon.moves = { + { id = "WRAP", pp = 20, maxPp = 20 }, + { id = "LEER", pp = 30, maxPp = 30 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, + { id = "TWISTER", pp = 20, maxPp = 20 }, + } + return mon +end + +local function moveIds(mon) + local out = {} + for _, move in ipairs(mon.moves or {}) do out[#out + 1] = move.id end + return table.concat(out, ",") +end + +do + local record = newSave() + record.party = { { species = "CHIKORITA" }, dratini("FIRST"), + dratini("LAST") } + local vm = makeVm({ save = record, scriptVar = 0 }) + H.GiveDratini(vm) + eq(moveIds(record.party[3]), "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED", + "scriptVar 0 gives .Moveset0, Extremespeed and all") + eq(moveIds(record.party[2]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "and only the LAST Dratini in the party is touched") + eq(record.party[3].moves[4].pp, 5, + "the new move's PP comes out of the move table") + eq(record.party[3].moves[4].maxPp, 5, "and so does its max PP") + + local plain = newSave() + plain.party = { dratini("ONLY") } + local vm2 = makeVm({ save = plain, scriptVar = 1 }) + H.GiveDratini(vm2) + eq(moveIds(plain.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "scriptVar 1 gives .Moveset1, the plain level 15 set") + + local untouched = newSave() + untouched.party = { dratini("ONLY") } + local vm3 = makeVm({ save = untouched, scriptVar = 2 }) + H.GiveDratini(vm3) + eq(moveIds(untouched.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "`cp $2 / ret nc` leaves the party alone") + + local none = newSave() + none.party = { { species = "CHIKORITA", moves = {} } } + local vm4 = makeVm({ save = none, scriptVar = 0 }) + H.GiveDratini(vm4) + eq(#none.party[1].moves, 0, "a party with no Dratini is left alone") +end + +-- ---- GiveOddEgg ----------------------------------------------------------- +-- pokecrystal/engine/events/odd_egg.asm:1 + +-- pokecrystal/data/events/odd_eggs.asm:14-33 run through the macro at :5: +-- cumulative percent * $ffff / 100. +local ODD_EGG_PROBABILITIES = { 5242, 5898, 16383, 18349, 28835, 30801, 39976, + 41287, 47840, 49151, 57015, 58326, 64879, 65535 } + +-- pokecrystal/data/events/odd_eggs.asm:37 OddEggs, row by row. +local ODD_EGG_ROWS = { + { "PICHU", 2048, { 0, 0, 0, 0 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + { 17, 9, 6, 11, 8, 8 } }, + { "PICHU", 256, { 2, 10, 10, 10 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + { 17, 9, 7, 12, 9, 9 } }, + { "CLEFFA", 4096, { 0, 0, 0, 0 }, { "POUND", "CHARM", "DIZZY_PUNCH" }, + { 20, 7, 7, 6, 9, 10 } }, + { "CLEFFA", 768, { 2, 10, 10, 10 }, { "POUND", "CHARM", "DIZZY_PUNCH" }, + { 20, 7, 8, 7, 10, 11 } }, + { "IGGLYBUFF", 4096, { 0, 0, 0, 0 }, { "SING", "CHARM", "DIZZY_PUNCH" }, + { 24, 8, 6, 6, 9, 7 } }, + { "IGGLYBUFF", 768, { 2, 10, 10, 10 }, { "SING", "CHARM", "DIZZY_PUNCH" }, + { 24, 8, 7, 7, 10, 8 } }, + { "SMOOCHUM", 3584, { 0, 0, 0, 0 }, { "POUND", "LICK", "DIZZY_PUNCH" }, + { 19, 8, 6, 11, 13, 11 } }, + { "SMOOCHUM", 512, { 2, 10, 10, 10 }, { "POUND", "LICK", "DIZZY_PUNCH" }, + { 19, 8, 7, 12, 14, 12 } }, + { "MAGBY", 2560, { 0, 0, 0, 0 }, { "EMBER", "DIZZY_PUNCH" }, + { 19, 12, 8, 13, 12, 10 } }, + { "MAGBY", 512, { 2, 10, 10, 10 }, { "EMBER", "DIZZY_PUNCH" }, + { 19, 12, 9, 14, 13, 11 } }, + { "ELEKID", 3072, { 0, 0, 0, 0 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + { 19, 11, 8, 14, 11, 10 } }, + { "ELEKID", 512, { 2, 10, 10, 10 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + { 19, 11, 9, 15, 12, 11 } }, + { "TYROGUE", 2560, { 0, 0, 0, 0 }, { "TACKLE", "DIZZY_PUNCH" }, + { 18, 8, 8, 8, 8, 8 } }, + { "TYROGUE", 256, { 2, 10, 10, 10 }, { "TACKLE", "DIZZY_PUNCH" }, + { 18, 8, 9, 9, 9, 9 } }, +} + +-- Specials.random(n) is 1..n, so `roll` is the Random WORD the routine reads. +local function pinRoll(roll) + Specials.random = function(n) + if n == 0x10000 then return roll + 1 end + return priorRandom(n) + end +end + +do + local wrong = {} + for index, row in ipairs(ODD_EGG_ROWS) do + local record = newSave() + -- The lowest roll that lands in this bucket: one past the previous + -- cumulative probability. + local roll = (index == 1) and 0 or (ODD_EGG_PROBABILITIES[index - 1] + 1) + pinRoll(roll) + local vm = makeVm({ save = record }) + H.GiveOddEgg(vm) + local egg = record.party[1] + local label = "row " .. index .. " (" .. row[1] .. ")" + if not egg then + wrong[#wrong + 1] = label .. ": no egg" + else + if egg.species ~= row[1] then + wrong[#wrong + 1] = label .. ": species " .. tostring(egg.species) + end + if egg.otId ~= row[2] then + wrong[#wrong + 1] = label .. ": otId " .. tostring(egg.otId) + end + local dvs = egg.dvs or {} + if dvs.attack ~= row[3][1] or dvs.defense ~= row[3][2] + or dvs.speed ~= row[3][3] or dvs.special ~= row[3][4] then + wrong[#wrong + 1] = label .. ": DVs" + end + if moveIds(egg) ~= table.concat(row[4], ",") then + wrong[#wrong + 1] = label .. ": moves " .. moveIds(egg) + end + for slot, id in ipairs(row[4]) do + if egg.moves[slot].pp ~= MOVE_PP[id] then + wrong[#wrong + 1] = label .. ": pp " .. id + end + end + local stats = egg.stats or {} + local want = row[5] + if stats.hp ~= want[1] or stats.attack ~= want[2] + or stats.defense ~= want[3] or stats.speed ~= want[4] + or stats.specialAttack ~= want[5] + or stats.specialDefense ~= want[6] then + wrong[#wrong + 1] = label .. ": stats" + end + -- The struct's stat words are the Gen 2 formula's own output; if these + -- two ever disagree the transcription above is wrong, not the formula. + local computed = Mon.stats(DATA.pokemon[row[1]].baseStats, egg.dvs, 5, {}) + if computed.hp ~= want[1] or computed.attack ~= want[2] + or computed.defense ~= want[3] or computed.speed ~= want[4] + or computed.specialAttack ~= want[5] + or computed.specialDefense ~= want[6] then + wrong[#wrong + 1] = label .. ": Mon.stats disagrees with the table" + end + end + end + eq(table.concat(wrong, " | "), "", + "all 14 OddEggs rows come out byte for byte") + + -- The bucket boundary is inclusive: `cp e / jr z, .done`. + pinRoll(ODD_EGG_PROBABILITIES[1]) + local edge = newSave() + H.GiveOddEgg(makeVm({ save = edge })) + eq(edge.party[1].otId, 2048, "a roll EQUAL to the probability stays in row 1") + pinRoll(ODD_EGG_PROBABILITIES[1] + 1) + local over = newSave() + H.GiveOddEgg(makeVm({ save = over })) + eq(over.party[1].otId, 256, "and one past it falls into row 2") + + -- The last entry is $ffff, which is the loop's own break: every roll above + -- the 99% mark lands there. + pinRoll(0xffff) + local last = newSave() + H.GiveOddEgg(makeVm({ save = last })) + eq(last.party[1].species, "TYROGUE", "the $ffff row takes the top of the range") + eq(last.party[1].otId, 256, "and it is the shiny Tyrogue") +end + +do + pinRoll(0) + local record = newSave() + local vm = makeVm({ save = record }) + H.GiveOddEgg(vm) + local egg = record.party[1] + eq(egg.isEgg, true, "the Odd Egg arrives as an EGG") + eq(egg.nickname, "EGG", "wOddEggName is its nickname") + eq(egg.ot, "ODD", "and wTempOddEggNickname is its OT name") + eq(egg.otName, "ODD", "on both OT fields") + eq(egg.otId, 2048, "the OT ID is the struct's, not the player's") + eq(egg.eggSteps, 20, "20 step cycles to hatch") + eq(egg.hp, 0, "an egg is carried at zero HP") + eq(egg.level, 5, "at level 5") + eq(egg.experience, 125, + "and with the struct's 125 exp, not the curve's own value") + eq(#vm.tossed, 1, "the EGG TICKET is tossed") + eq(vm.tossed[1].index, 129, "by its item index") + eq(vm.tossed[1].qty, 1, "one of them") + + -- CLEFFA is GROWTH_FAST, so 125 is more than level 5 costs: the copied + -- struct really does overshoot the curve. + pinRoll(ODD_EGG_PROBABILITIES[2] + 1) + local cleffa = newSave() + H.GiveOddEgg(makeVm({ save = cleffa })) + eq(cleffa.party[1].species, "CLEFFA", "the Cleffa bucket") + eq(cleffa.party[1].experience, 125, "carries 125 exp too") + eq(Mon.experienceForLevel(GROWTH.GROWTH_FAST, 5), 100, + "even though its own curve wants 100") + + -- The second row of every pair is the classic shiny DV set. + pinRoll(ODD_EGG_PROBABILITIES[1] + 1) + local shiny = newSave() + H.GiveOddEgg(makeVm({ save = shiny })) + eq(shiny.party[1].shiny, true, "the 2/10/10/10 rows are shiny") + + -- pokecrystal/maps/DayCare.asm:31-32 is the party-full guard, so a full + -- party must not grow a seventh slot here. + local full = newSave() + for i = 1, 6 do full.party[i] = { species = "CHIKORITA" } end + local vmFull = makeVm({ save = full }) + H.GiveOddEgg(vmFull) + eq(#full.party, 6, "a full party gets no Odd Egg") + eq(#vmFull.tossed, 0, "and keeps its EGG TICKET") +end + +Specials.random = priorRandom + +-- ---- the Ilex Forest shrine ----------------------------------------------- +-- pokecrystal/engine/events/celebi.asm:9 and :301 + +do + local record = newSave() + local vm = makeVm({ save = record }) + H.CelebiShrineEvent(vm) + eq(vm.vars[0x03], 11, + "CelebiShrineEvent leaves BATTLETYPE_CELEBI in VAR_BATTLETYPE") + eq(vm.celebiArmed, true, "and arms the result bit for the battle to come") + + vm.battleOutcome = "caught" + H.CheckCaughtCelebi(vm) + eq(vm.scriptVar, 1, "catching it in that battle answers TRUE") + eq(Save.crystalState(record).celebiCaught, true, "and is recorded") + eq(vm.celebiArmed, nil, "the arming is one battle long") + + local ran = newSave() + local vm2 = makeVm({ save = ran, battleOutcome = "run" }) + H.CelebiShrineEvent(vm2) + H.CheckCaughtCelebi(vm2) + eq(vm2.scriptVar, 0, "running from it answers FALSE") + eq(Save.crystalState(ran).celebiCaught, false, "and records nothing") + + local won = newSave() + local vm3 = makeVm({ save = won, battleOutcome = "win" }) + H.CelebiShrineEvent(vm3) + H.CheckCaughtCelebi(vm3) + eq(vm3.scriptVar, 0, "and knocking it out answers FALSE") + + -- pokecrystal/engine/items/item_effects.asm:542 gates the bit on the battle + -- type, so an ordinary catch never sets it. + local other = newSave() + local vm4 = makeVm({ save = other, battleOutcome = "caught" }) + H.CheckCaughtCelebi(vm4) + eq(vm4.scriptVar, 0, "a catch in a NORMAL battle does not count") + eq(Save.crystalState(other).celebiCaught, false, "and records nothing") +end + +-- ---- the beasts that roam ------------------------------------------------- +-- pokegold/data/wild/flee_mons.asm:34 against pokecrystal's:34 + +do + GameVersion.set("gold") + eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Gold: Raikou always flees") + eq(Roamers.ALWAYS_FLEE.ENTEI, true, "Gold: Entei always flees") + eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Gold: Suicune always flees") + + GameVersion.set("silver") + eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Silver rides the same list") + + GameVersion.set("crystal") + eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Crystal keeps Raikou on the list") + eq(Roamers.ALWAYS_FLEE.ENTEI, true, "and Entei") + eq(Roamers.ALWAYS_FLEE.SUICUNE, nil, + "but drops Suicune, so the Tin Tower battle can be fought") + eq(Roamers.ALWAYS_FLEE.PIDGEY, nil, "a mon on no list still never flees") + + eq(Roamers.alwaysFleeMons("gold").SUICUNE, true, + "the accessor answers per version id") + eq(Roamers.alwaysFleeMons("crystal").SUICUNE, nil, "independently of the boot") + GameVersion.set(priorVersion) +end + +-- Crystal seeds two roam structs, so the third slot the encounter roll can +-- pick is always empty -- pokecrystal/engine/overworld/wildmons.asm:493. +do + local twoRow = { roamMons = { + { species = "RAIKOU", level = 40, map = "ROUTE_42" }, + { species = "ENTEI", level = 40, map = "ROUTE_37" }, + } } + local record = { version = "crystal" } + Roamers.init(record, { encounters = twoRow }) + eq(#record.roamers, 2, "a Crystal cache seeds two roamers") + eq(record.roamers[1].species, "RAIKOU", "Raikou in slot 1") + eq(record.roamers[2].species, "ENTEI", "Entei in slot 2") + eq(record.roamers[3], nil, "and nothing in Suicune's old slot") + + -- CheckEncounterRoamMon's `and %11 / jr z / dec a` picks slot 1, 2 or 3. + local hits = { 0, 0, 0 } + for value = 0, 99 do + local hit = Roamers.checkEncounter(record, "ROUTE_42", false, + function() return value end) + if hit then hits[hit.index] = hits[hit.index] + 1 end + end + check(hits[1] > 0, "slot 1 is still reachable on its own route") + eq(hits[3], 0, "slot 3 never produces an encounter on Crystal") +end + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + for _, dir in ipairs({ "crystal-dev", "lead-card" }) do + local candidate = home .. "/Library/Application Support/LOVE/" .. dir + .. "/crystal" + local probe = io.open(candidate .. "/data/generated/constants.lua", "r") + if probe then + probe:close() + cache = candidate + break + end + end +end + +local constantsFile = cache and io.open(cache .. "/data/generated/constants.lua", + "r") +if not constantsFile then + check(true, "crystal cache absent : SKIP") + S.finish() + return +end +constantsFile:close() + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local constants = loadLua("data/generated/constants.lua") +local scripts = loadLua("data/generated/scripts.lua") +local encounters = loadLua("data/generated/encounters.lua") +check(constants ~= nil and scripts ~= nil, "the crystal cache loads") + +-- Every handler above has to be a name the cache's own SpecialsPointers row +-- resolves to, and one an extracted script really calls. +local order = constants.specialOrder or {} +local calls = {} +for _, list in pairs(scripts) do + if type(list) == "table" then + for _, row in ipairs(list) do + if type(row) == "table" and row.op == "special" then + local name = order[(row.id or 0) + 1] + if name then calls[name] = (calls[name] or 0) + 1 end + end + end + end +end + +for _, name in ipairs({ "BeastsCheck", "GiveDratini", "GiveOddEgg", + "CelebiShrineEvent", "CheckCaughtCelebi" }) do + check(Specials.HANDLERS[name] ~= nil, name .. " is a handler, not a stub") + check((calls[name] or 0) > 0, + name .. " is called by an extracted Crystal script") +end + +-- pokecrystal/mobile/mobile_46.asm:6793: TradeCornerHoldMon lives in the +-- Mobile System GB bank and no map script reaches it. +eq(calls.TradeCornerHoldMon, nil, + "TradeCornerHoldMon is never called from a script") +check(Specials.STUBS.TradeCornerHoldMon ~= nil, + "so it stays a documented stub") + +eq(#(encounters and encounters.roamMons or {}), 2, + "the Crystal cache carries two roamMons rows") +local roamNames = {} +for _, row in ipairs((encounters and encounters.roamMons) or {}) do + roamNames[#roamNames + 1] = row.species +end +eq(table.concat(roamNames, ","), "RAIKOU,ENTEI", + "Raikou and Entei, with Suicune off the roam list") + +S.finish() diff --git a/tests/gen2_crystal_townmap_test.lua b/tests/gen2_crystal_townmap_test.lua new file mode 100644 index 00000000..d1e9b812 --- /dev/null +++ b/tests/gen2_crystal_townmap_test.lua @@ -0,0 +1,421 @@ +-- The wall map, the printed diploma and the memory game: the three specials +-- both Gen 2 carts carry that had no port half (engine/events/specials.asm:100 +-- OverworldTownMap, :448 PrintDiploma, :207 UnusedMemoryGame). +-- +-- OverworldTownMap has two callers. TownMapScript +-- (engine/events/std_scripts.asm:145) is registered but no map jumpstd's it on +-- either cart; DecorationDesc_TownMapPoster +-- (engine/overworld/decorations.asm:1005) is the live one, and InitDecorations +-- (engine/overworld/decorations.asm:1, run from +-- engine/menus/intro_menu.asm:133) hangs DECO_TOWN_MAP on the bedroom wall at +-- new game, so a silent no-op here is a dead end in the room the player starts +-- in. +-- luajit tests/gen2_crystal_townmap_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal town map") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local Events = require("src.world.gen2.Events") +local Pokegear = require("src.ui.gen2.Pokegear") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") + +local H = Specials.HANDLERS + +local function fakeInput() + local pressed = {} + return { + press = function(_self, button) pressed[button] = true end, + wasPressed = function(_self, button) + if pressed[button] then + pressed[button] = nil + return true + end + return false + end, + } +end + +-- landmarks.lua's shape. ../pokecrystal/constants/landmark_constants.asm:34 +-- inserts LANDMARK_BATTLE_TOWER, so every index from ROUTE_40 up is one higher +-- than pokegold/constants/landmark_constants.asm gives it; both index spaces +-- are built here because the two limit registers are read out of this table. +local function landmarkTable(rows) + local out = { landmarks = {}, order = {} } + for id, index in pairs(rows) do + out.landmarks[id] = { + id = id, index = index, + name = id:gsub("^LANDMARK_", ""):gsub("_", " "), x = 8, y = 8, + } + out.order[index + 1] = id + end + return out +end + +local GOLD_LANDMARKS = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_ROUTE_29 = 0x02, + LANDMARK_SILVER_CAVE = 0x2d, + LANDMARK_PALLET_TOWN = 0x2e, + LANDMARK_VICTORY_ROAD = 0x57, + LANDMARK_ROUTE_28 = 0x5d, + LANDMARK_FAST_SHIP = 0x5e, +}) + +local CRYSTAL_LANDMARKS = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_ROUTE_29 = 0x02, + LANDMARK_BATTLE_TOWER = 0x1d, + LANDMARK_SILVER_CAVE = 0x2e, + LANDMARK_PALLET_TOWN = 0x2f, + LANDMARK_VICTORY_ROAD = 0x58, + LANDMARK_ROUTE_28 = 0x5e, + LANDMARK_FAST_SHIP = 0x5f, +}) + +local function townMapScreen(opts) + opts = opts or {} + local input = fakeInput() + local save = opts.save or {} + local popped = 0 + local game = { + input = input, + save = save, + stack = { pop = function() popped = popped + 1 end }, + } + local closed = 0 + local screen = Pokegear.new(game, { + save = save, + landmarks = opts.landmarks or CRYSTAL_LANDMARKS, + currentLandmark = opts.currentLandmark or "LANDMARK_NEW_BARK_TOWN", + townMap = true, + onClose = function() closed = closed + 1 end, + }) + return screen, input, + function() return closed end, function() return popped end +end + +-- ================================================= 1. the screen's own mode +-- +-- _TownMap (engine/pokegear/pokegear.asm:1709) reuses PokegearMap for the art +-- and nothing else off the card: no strip, no ENGINE_MAP_CARD test. +do + local screen = townMapScreen() + eq(#screen.cards, 1, "the poster is one screen, not the card strip") + eq(screen.mode, "card", "and it opens straight onto the map") + eq(screen:card().id, "map", "on the MAP card's own art") + eq(screen.fly, nil, "and it is not the fly picker") +end + +-- The card gate is _FlyMap's absence, not the gear's: a player who has never +-- met the Guide Gent still reads the poster in their bedroom. +do + local screen = townMapScreen({ save = { engineFlags = {} } }) + eq(#screen.cards, 1, "no ENGINE_MAP_CARD is needed to open it") +end + +-- ================================================= 2. the joypad +-- +-- `.loop` (engine/pokegear/pokegear.asm:1783): PAD_B returns, PAD_UP and +-- PAD_DOWN walk wTownMapCursorLandmark, and nothing else is read at all. +do + local screen, input, closed, popped = townMapScreen() + local first = CRYSTAL_LANDMARKS.landmarks.LANDMARK_NEW_BARK_TOWN.index + local last = CRYSTAL_LANDMARKS.landmarks.LANDMARK_SILVER_CAVE.index + eq(screen:mapCursorIndex(), first, "the cursor starts on the player's own") + + input:press("up") + screen:update(0) + eq(screen:mapCursorIndex(), first + 1, "UP steps to the next landmark") + input:press("down") + screen:update(0) + eq(screen:mapCursorIndex(), first, "DOWN steps back") + + -- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps. + input:press("down") + screen:update(0) + eq(screen:mapCursorIndex(), last, "and wraps off the first onto the last") + -- `cp d / jr c, .wrap_around_up`. + input:press("up") + screen:update(0) + eq(screen:mapCursorIndex(), first, "and off the last back onto the first") + + -- Left, right and A are never read by _TownMap's loop. + local held = screen:mapCursorIndex() + for _, button in ipairs({ "left", "right", "a", "start", "select" }) do + input:press(button) + screen:update(0) + end + eq(screen:mapCursorIndex(), held, "no other button moves the cursor") + eq(closed(), 0, "and none of them leaves the map") + + input:press("b") + screen:update(0) + eq(closed(), 1, "B returns from the map") + eq(popped(), 1, "and the screen the special pushed comes back off the stack") + + -- The pop already happened; a second B must not take the overworld with it. + input:press("b") + screen:update(0) + eq(popped(), 1, "a second B pops nothing") +end + +-- ================================================= 3. the limit registers +-- +-- `ld d, KANTO_LANDMARK - 1 / ld e, 1` for Johto and +-- TownMap_GetKantoLandmarkLimits (engine/pokegear/pokegear.asm:714) for Kanto. +-- Both are LANDMARK indices, and Crystal's are not Gold's. +do + local crystal = townMapScreen({ landmarks = CRYSTAL_LANDMARKS }) + local last, first = crystal:cursorLimits() + eq(first, 0x01, "Johto's e is NEW BARK TOWN either way round") + eq(last, 0x2e, "and its d is Crystal's own SILVER CAVE, not Gold's $2d") + + local gold = townMapScreen({ landmarks = GOLD_LANDMARKS }) + local goldLast = gold:cursorLimits() + eq(goldLast, 0x2d, "a Gold landmark table still answers $2d") + + -- A dataset with no landmark records at all keeps the pokegold numbers. + local bare = townMapScreen({ landmarks = { landmarks = {}, order = {} } }) + local bareLast, bareFirst = bare:cursorLimits() + eq(bareLast, 0x2d, "and so does a dataset with no records") + eq(bareFirst, 0x01, "with NEW BARK TOWN still the floor") +end + +-- `cp KANTO_LANDMARK` is the region test, and KANTO_LANDMARK is PALLET_TOWN's +-- own index: $2e on Gold, $2f on Crystal. The byte $2e therefore reads as +-- Kanto on one cart and Johto (SILVER CAVE) on the other. +do + local silver = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_SILVER_CAVE", + }) + eq(silver:region(), "johto", "Crystal's $2e is SILVER CAVE, so Johto") + local pallet = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + }) + eq(pallet:region(), "kanto", "and its $2f is PALLET TOWN, so Kanto") + local goldPallet = townMapScreen({ + landmarks = GOLD_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + }) + eq(goldPallet:region(), "kanto", "Gold's PALLET TOWN is Kanto at $2e") + -- LANDMARK_FAST_SHIP sits past every Kanto landmark and is still Johto. + local ship = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_FAST_SHIP", + }) + eq(ship:region(), "johto", "and the S.S. Aqua stays Johto at $5f") +end + +-- Kanto's pair, with and without the Hall of Fame on the record. +do + local before = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + save = { flags = {} }, + }) + local lastB, firstB = before:cursorLimits() + eq(firstB, 0x58, "before the Hall of Fame Kanto starts at VICTORY ROAD") + eq(lastB, 0x5e, "and ends at ROUTE 28") + local after = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + save = { flags = { HALL_OF_FAME = true } }, + }) + local _, firstA = after:cursorLimits() + eq(firstA, 0x2f, "and afterwards the whole region opens at PALLET TOWN") +end + +-- ================================================= 4. the tilemap frame +-- +-- _TownMap.InitTilemap (engine/pokegear/pokegear.asm:1843): the rule turns down +-- at (7,0) and runs back out along row 2, which is what boxes the name plate +-- into the top right corner with no card strip above it. +do + local screen = townMapScreen() + local laid = {} + screen.tile = function(_self, id, tx, ty) + laid[tx .. "," .. ty] = id + end + screen:drawTownMapRule() + + eq(laid["0,0"], 0x06, "$06 caps the rule at (0,0)") + local run = true + for x = 1, 6 do run = run and laid[x .. ",0"] == 0x07 end + check(run, "$07 runs (1,0) to (6,0), the `ld bc, 6` ByteFill") + eq(laid["7,0"], 0x17, "$17 turns it down at (7,0)") + eq(laid["7,1"], 0x16, "$16 carries it through (7,1)") + eq(laid["7,2"], 0x26, "$26 turns it back out at (7,2)") + local tail = true + for x = 8, 18 do tail = tail and laid[x .. ",2"] == 0x07 end + check(tail, "$07 runs (8,2) to (18,2), the NAME_LENGTH ByteFill") + eq(laid["19,2"], 0x17, "$17 caps it at (19,2)") + eq(laid["8,1"], nil, "and nothing is laid inside the name plate") + + -- The MAP card's own rule is the other shape: a full-width bar under the + -- two-row card strip. The poster must not draw that. + local card = Pokegear.new({ input = fakeInput(), save = {} }, { + save = { pokegearFlags = { map = true } }, + landmarks = CRYSTAL_LANDMARKS, + currentLandmark = "LANDMARK_NEW_BARK_TOWN", + }) + eq(card.townMap, nil, "the POKeGEAR card is not in town-map mode") + check(#card.cards > 1, "and it keeps its card strip") +end + +-- Drawing with no town-map art at all: a crash check on the unstyled fallback. +do + local screen = townMapScreen() + check(not screen:styled(), "this harness has no gear sheet") + check(pcall(function() screen:draw() end), "the poster still draws") +end + +-- ================================================= 5. the specials +-- +-- World:specialHooks, stubbed down to what these two reach for. +local function newHooks(opts) + opts = opts or {} + local log = { pushed = {}, diploma = 0 } + local hooks = { + log = log, + save = function() return opts.save or {} end, + data = function() return {} end, + party = function() return {} end, + } + if not opts.noScreens then + hooks.pushScreen = function(id, screenOpts) + log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts } + if opts.pushFails then return false end + log.pending = screenOpts.onClose + if not opts.hold and screenOpts.onClose then screenOpts.onClose() end + return true + end + hooks.showDiploma = function(onDone) + log.diploma = log.diploma + 1 + log.pending = onDone + if not opts.hold and onDone then onDone() end + end + end + return hooks +end + +local function newVm(hooks, scriptVar) + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + vm.scriptVar = scriptVar or 0 + return vm +end + +-- Run a handler to its first park (or to the end), the way the VM runs it. +local function run(vm, handler) + local co = coroutine.create(function() handler(vm) end) + vm.co = co + local ok, req = coroutine.resume(co) + if not ok then error(req, 0) end + return co, req +end + +-- ---- OverworldTownMap ------------------------------------------------------ + +check(H.OverworldTownMap ~= nil, "OverworldTownMap has a handler") +eq(Specials.STUBS.OverworldTownMap, nil, "and is no longer a stub") +check(Specials.SUPERSEDED_STUBS.OverworldTownMap ~= nil, + "its old reason moved to the superseded ledger") + +do + local hooks = newHooks() + local vm = newVm(hooks, 7) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "an answering screen runs it straight out") + eq(#hooks.log.pushed, 1, "one screen is pushed") + eq(hooks.log.pushed[1].id, "Gen2Pokegear", "which is the POKeGEAR's own map") + eq(hooks.log.pushed[1].opts.townMap, true, "in _TownMap's chrome-free mode") + -- FadeToMenu / farcall _TownMap / ExitAllMenus writes no wScriptVar, and the + -- callers' next op is a bare `closetext`. + eq(vm.scriptVar, 7, "and wScriptVar is left exactly as `special` found it") +end + +-- The block is real: the coroutine parks until the screen answers. +do + local hooks = newHooks({ hold = true }) + local vm = newVm(hooks, 0) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "suspended", "the script waits on the map") + hooks.log.pending() + eq(coroutine.status(co), "dead", "and only B off the map resumes it") +end + +-- A run with no screen to push (a headless probe) must still not fall through +-- into the next command with a stale wScriptVar. +do + local hooks = newHooks({ pushFails = true }) + local vm = newVm(hooks, 3) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "a refused push does not hang the script") + eq(vm.scriptVar, 3, "and still leaves wScriptVar alone") +end +do + local hooks = newHooks({ noScreens = true }) + local vm = newVm(hooks, 3) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "and neither does no pushScreen hook") + eq(vm.scriptVar, 3, "with wScriptVar untouched") +end + +-- ---- PrintDiploma ---------------------------------------------------------- + +check(H.PrintDiploma ~= nil, "PrintDiploma has a handler") +eq(Specials.STUBS.PrintDiploma, nil, "and is no longer a stub") +check(Specials.SUPERSEDED_STUBS.PrintDiploma ~= nil, + "its printer reason moved to the superseded ledger") + +do + local hooks = newHooks() + local vm = newVm(hooks, 5) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "dead", "the page runs out") + eq(hooks.log.diploma, 1, "PlaceDiplomaOnScreen is the routine's first act") + eq(vm.scriptVar, 5, "and _PrintDiploma writes no wScriptVar either") +end + +do + local hooks = newHooks({ hold = true }) + local vm = newVm(hooks, 0) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "suspended", "the script waits on the page") + hooks.log.pending() + eq(coroutine.status(co), "dead", "and the dismissal resumes it") +end + +do + local hooks = newHooks({ noScreens = true }) + local vm = newVm(hooks, 4) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "dead", "no diploma hook is not a hang") + eq(vm.scriptVar, 4, "and wScriptVar survives") +end + +-- ---- UnusedMemoryGame ------------------------------------------------------ +-- +-- DELIBERATELY UNREACHABLE, and not a Crystal gap: `add_special +-- UnusedMemoryGame ; unused` is the row in BOTH carts +-- (pokegold data/events/special_pointers.asm:63, +-- ../pokecrystal/data/events/special_pointers.asm:58) and no script bytecode +-- in either ROM points at it. _MemoryGame (engine/games/memory_game.asm) is a +-- 590-line Game Corner screen with its own board, cursor sprite anim and coin +-- payout, so porting it would be a whole new screen reachable by nobody. +-- UnusedDummySpecial next to it is a bare `ret` and IS ported, because that +-- costs one line; this one is not, and stays a stub with its reason recorded. +check(Specials.STUBS.UnusedMemoryGame ~= nil, + "UnusedMemoryGame stays a stub: no cart script reaches it") +check(type(Specials.STUB_REASONS.UnusedMemoryGame) == "string", + "and it still says why") +-- engine/events/specials.asm:207 is CheckCoinsAndCoinCase then +-- StartGameCornerGame, neither of which writes wScriptVar. +do + local vm = newVm(newHooks(), 9) + Specials.STUBS.UnusedMemoryGame(vm) + eq(vm.scriptVar, 9, "and leaves wScriptVar alone, as the routine does") +end + +S.finish() diff --git a/tests/gen2_crystal_vars_test.lua b/tests/gen2_crystal_vars_test.lua new file mode 100644 index 00000000..9944a0b5 --- /dev/null +++ b/tests/gen2_crystal_vars_test.lua @@ -0,0 +1,401 @@ +-- The six .VarActionTable rows Crystal appends past VAR_SPECIALPHONECALL, and +-- TryQuickSave -- the save Link_SaveGame runs behind the Battle Tower desk. +-- ROM-free: +-- luajit tests/gen2_crystal_vars_test.lua +-- +-- ../pokecrystal/constants/script_constants.asm:69-74, +-- ../pokecrystal/engine/overworld/variables.asm:62-67, +-- engine/link/link.asm:2356 and engine/menus/save.asm:63. +-- +-- The point of the World half is that these reads and writes land in the SAVE. +-- World.scriptVars is rebuilt empty on every map load (src/world/gen2/World.lua +-- :579), so a var parked there is a var Buena's Blue Card loses the moment the +-- player walks out of the Radio Tower. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 1 end } +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +local S = require("tests.harness").suite("gen2 crystal vars") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local Apricorns = require("src.core.gen2.Apricorns") +local Events = require("src.world.gen2.Events") +local Save = require("src.core.gen2.Save") +local SaveSerializer = require("src.core.SaveSerializer") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +local H = Specials.HANDLERS + +-- ../pokecrystal/constants/script_constants.asm:69-74. +local VAR_BT_WIN_STREAK = 0x15 +local VAR_KURT_APRICORNS = 0x16 +local VAR_CALLERID = 0x17 +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 +local VAR_KENJI_BREAK = 0x1a + +-- ---------------------------------------------------------------- fixtures + +local DATA = { + audio = {}, + items = { + LEVEL_BALL = { index = 5, name = "LEVEL BALL" }, + RED_APRICORN = { index = 78, name = "RED APRICORN" }, + BLU_APRICORN = { index = 79, name = "BLU APRICORN" }, + }, + pokemon = {}, + moves = {}, +} + +local function newSave() + local save = { + version = "crystal", + player = { name = "KRIS", id = 0x1234, money = 0 }, + party = {}, + inventory = {}, + bagOrder = {}, + boxes = {}, + } + Save.crystalState(save) + Save.battleTowerState(save) + return save +end + +local function newWorld(save) + local game = { data = DATA, save = save, stack = nil } + local world = World.new(game) + world.vm = { curPhoneCaller = 0 } + return world, game +end + +-- ============================================ the ids, against the cart's own +do + local save = newSave() + local world = newWorld(save) + -- .VarActionTable is walked by index, so a row in the wrong slot reads a + -- different variable. Pin the six by writing a distinct value into each + -- store and reading it back through the id the script byte carries. + Save.battleTowerState(save).streak = 4 + save.kurtApricornQuantity = 3 + world.vm.curPhoneCaller = 11 + Save.crystalState(save).buenaPassword.balance = 17 + Save.crystalState(save).buenaPassword.word = 0x52 + Save.crystalState(save).kenjiBreak = 5 + + eq(world:readVar(VAR_BT_WIN_STREAK), 4, "$15 is wNrOfBeatenBattleTowerTrainers") + eq(world:readVar(VAR_KURT_APRICORNS), 3, "$16 is wKurtApricornQuantity") + eq(world:readVar(VAR_CALLERID), 11, "$17 is wCurCaller") + eq(world:readVar(VAR_BLUECARDBALANCE), 17, "$18 is wBlueCardBalance") + eq(world:readVar(VAR_BUENASPASSWORD), 0x52, "$19 is wBuenasPassword") + eq(world:readVar(VAR_KENJI_BREAK), 5, "$1a is wKenjiBreakTimer") + -- NUM_VARS' own guard: nothing above the table answers with a neighbour. + eq(world:readVar(0x1b), 0, "$1b is past the table and reads 0") +end + +-- ================================================= the three writable rows +-- +-- ../pokecrystal/engine/overworld/variables.asm:21-25: only a RETVAR_ADDR_DE +-- row hands `Script_writevar` the variable's own address. The other three +-- rows write wStringBuffer2, which nothing reads back. +do + local save = newSave() + local world = newWorld(save) + + world:writeVar(VAR_BLUECARDBALANCE, 6) + eq(Save.crystalState(save).buenaPassword.balance, 6, + "writevar VAR_BLUECARDBALANCE lands in the save") + eq(world:readVar(VAR_BLUECARDBALANCE), 6, "and reads straight back") + + world:writeVar(VAR_BUENASPASSWORD, 0x31) + eq(Save.crystalState(save).buenaPassword.word, 0x31, + "writevar VAR_BUENASPASSWORD lands in the save") + + world:writeVar(VAR_CALLERID, 9) + eq(world.vm.curPhoneCaller, 9, "loadvar VAR_CALLERID parks wCurCaller") + eq(world:readVar(VAR_CALLERID), 9, "and the callee scripts read it back") + + Save.battleTowerState(save).streak = 2 + world:writeVar(VAR_BT_WIN_STREAK, 99) + eq(Save.battleTowerState(save).streak, 2, + "a RETVAR_STRBUF2 row is not writable") + eq(world:readVar(VAR_BT_WIN_STREAK), 2, "so the streak is unchanged") + + -- One byte wide, `ld [de], a`. + world:writeVar(VAR_BLUECARDBALANCE, 300) + eq(world:readVar(VAR_BLUECARDBALANCE), 300 % 256, "the store is one byte") +end + +-- =========================================== they survive a save and a reload +-- +-- The whole bug: scriptVars is rebuilt empty at World.lua:579 and never +-- serialized, so a balance parked there is gone by the next map. +do + local save = newSave() + local world = newWorld(save) + world:writeVar(VAR_BLUECARDBALANCE, 12) + world:writeVar(VAR_BUENASPASSWORD, 0x24) + + eq(world.scriptVars[VAR_BLUECARDBALANCE], 12, + "the scratch mirror still holds it in this session") + world.scriptVars = {} + eq(world:readVar(VAR_BLUECARDBALANCE), 12, + "and a map load that clears the mirror does not lose it") + + local reloaded = SaveSerializer.decode(SaveSerializer.encode(save)) + Save.normalize(reloaded) + local second = newWorld(reloaded) + eq(second:readVar(VAR_BLUECARDBALANCE), 12, "a reload still has the balance") + eq(second:readVar(VAR_BUENASPASSWORD), 0x24, "and the day's password") +end + +-- ============================ ../pokecrystal/maps/RadioTower2F.asm:144-146 +-- +-- readvar VAR_BLUECARDBALANCE / addval 1 / writevar VAR_BLUECARDBALANCE +-- +-- run through the VM against a real World, which is the round trip the gate +-- caught reading `blue card balance: 0`. +do + local save = newSave() + local world = newWorld(save) + local vm = Vm.new({}, {}, Events.new(), { + readVar = function(id) return world:readVar(id) end, + writeVar = function(id, value) world:writeVar(id, value) end, + }) + local function awardPoint() + vm:start({ + { op = "readvar", var = VAR_BLUECARDBALANCE }, + { op = "addval", args = { 1 } }, + { op = "writevar", var = VAR_BLUECARDBALANCE }, + }) + local guard = 0 + while vm:running() and guard < 64 do + vm:update() + guard = guard + 1 + end + check(not vm:running(), "the three rows ran to the end") + end + awardPoint() + eq(world:readVar(VAR_BLUECARDBALANCE), 1, "a correct password is worth 1") + awardPoint() + awardPoint() + eq(Save.crystalState(save).buenaPassword.balance, 3, + "three shows is three points, in the save") +end + +-- ======================================= Kurt's quantity, the other dead var +-- +-- ../pokecrystal/maps/KurtsHouse.asm:197 is +-- `verbosegiveitemvar LEVEL_BALL, VAR_KURT_APRICORNS`, so a var that reads 0 +-- hands over nothing at all. +do + local save = newSave() + save.inventory = { RED_APRICORN = 1 } + local world = newWorld(save) + local quantities = {} + local hooks = { + save = function() return save end, + data = function() return DATA end, + itemName = function(id) return (DATA.items[id] or {}).name or id end, + itemIndex = function(id) return (DATA.items[id] or {}).index end, + setKurtApricornQuantity = function(n) + quantities[#quantities + 1] = n + world:setKurtApricornQuantity(n) + end, + scriptMenu = function(_header, onChoose) onChoose(1) end, + } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() H.SelectApricornForKurt(vm) end) + local ok, err = coroutine.resume(vm.co) + check(ok, "SelectApricornForKurt ran: " .. tostring(err)) + eq(quantities[1], 0, "kurt.asm:24 clears the byte on the way in") + eq(quantities[2], 1, "and kurt.asm:45 records the apricorn it tossed") + eq(world:readVar(VAR_KURT_APRICORNS), 1, + "so verbosegiveitemvar hands over one ball, not none") + eq(save.inventory.RED_APRICORN, nil, "the apricorn left the pack") + -- .GaveKurtApricorns' own setevent, which the map script runs, not this + -- handler: the count must not be conditioned on it. + check(Apricorns.pending(save) == nil, "the event is still the script's to set") + + -- The cancel arm leaves the byte at zero, which is the `xor a` at :24. + local cancelled = {} + hooks.setKurtApricornQuantity = function(n) cancelled[#cancelled + 1] = n end + hooks.scriptMenu = function(header, onChoose) onChoose(#header.items) end + local vm2 = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm2.showTextFn = function() end + vm2.co = coroutine.create(function() H.SelectApricornForKurt(vm2) end) + coroutine.resume(vm2.co) + eq(#cancelled, 1, "a cancel writes the byte once") + eq(cancelled[1], 0, "and leaves it at zero") +end + +-- ========================== SampleKenjiBreakCountdown, the KENJI_BREAK writer +-- +-- ../pokecrystal/engine/overworld/time.asm:136: `Random / and %11 / add 3`. +do + eq(Specials.STUB_REASONS.SampleKenjiBreakCountdown, nil, + "SampleKenjiBreakCountdown is no longer a stub") + local save = newSave() + local world = newWorld(save) + local hooks = { setKenjiBreak = function(days) world:setKenjiBreak(days) end } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + local seen = {} + local priorRandom = Specials.random + for roll = 1, 4 do + Specials.random = function() return roll end + H.SampleKenjiBreakCountdown(vm) + seen[#seen + 1] = world:readVar(VAR_KENJI_BREAK) + end + Specials.random = priorRandom + eq(table.concat(seen, ","), "3,4,5,6", "three to six days, inclusive") + eq(Save.crystalState(save).kenjiBreak, 6, "and the countdown is in the save") +end + +-- ================================================================ TryQuickSave +-- +-- engine/link/link.asm:2356 -> engine/menus/save.asm:63 Link_SaveGame. +-- ../pokecrystal/maps/BattleTower1F.asm:84-85 is `special TryQuickSave / +-- iffalse Script_Menu_ChallengeExplanationCancel`, so a FALSE here is a challenge that +-- never starts. +do + eq(Specials.STUB_REASONS.TryQuickSave, nil, "TryQuickSave is no longer a stub") + eq(Specials.HANDLER_SOURCE.TryQuickSave, "Specials.lua", + "and Specials.lua owns the handler") +end + +local function runQuickSave(opts) + local save = newSave() + local log = { writes = 0, sfx = {} } + local hooks = { + save = function() return save end, + data = function() return DATA end, + saveFileState = function() return opts.exists, opts.sameId end, + writeSave = function() + log.writes = log.writes + 1 + return opts.writeOk ~= false + end, + playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end, + } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + local pages, holds, asked = {}, {}, 0 + local co = coroutine.create(function() H.TryQuickSave(vm) end) + vm.co = co + local send + while true do + local ok, req = coroutine.resume(co, send) + if not ok then error(req, 0) end + if coroutine.status(co) == "dead" then break end + send = nil + if type(req) == "table" and req.kind == "text" then + pages[#pages + 1] = req.text + holds[#holds + 1] = req.hold or 0 + elseif type(req) == "table" and req.kind == "yesorno" then + asked = asked + 1 + send = opts.answers and opts.answers[asked] + else + error("TryQuickSave parked on " .. tostring(req and req.kind), 0) + end + end + log.pages, log.holds, log.asked = pages, holds, asked + log.scriptVar = vm.scriptVar + return log +end + +-- .yoursavefile: an existing file with this player's ID. +do + local log = runQuickSave({ exists = true, sameId = true, answers = { true } }) + eq(log.asked, 1, "AskOverwriteSaveFile asks once") + check(log.pages[1]:find("already a", 1, true) ~= nil, + "AlreadyASaveFileText is the question: " .. tostring(log.pages[1])) + eq(log.writes, 1, "_SaveGameData ran") + eq(log.scriptVar, 1, "and wScriptVar is TRUE") + check(log.pages[2]:find("SAVING", 1, true) ~= nil, "SAVING page printed") + eq(log.holds[2], 16 + 32, "held for save.asm:247 + :251") + check(log.pages[3]:find("KRIS saved", 1, true) ~= nil, + "SavedTheGameText names the player: " .. tostring(log.pages[3])) + eq(log.holds[3], 30 + 30, "held for save.asm:269 + link.asm:2367") + eq(log.sfx[1], "Sfx_Save", "SFX_SAVE rang") +end + +-- .refused: `jr nz, .refused / scf`, which TryQuickSave turns into FALSE. +do + local log = runQuickSave({ exists = true, sameId = true, answers = { false } }) + eq(log.asked, 1, "the question was asked") + eq(log.writes, 0, "NO writes nothing") + eq(log.scriptVar, 0, "and wScriptVar is FALSE") + eq(#log.pages, 1, "no SAVING page follows a refusal") +end + +-- The other ID: AnotherSaveFileText, and .erase rather than .ok. +do + local log = runQuickSave({ exists = true, sameId = false, answers = { true } }) + check(log.pages[1]:find("another", 1, true) ~= nil, + "AnotherSaveFileText: " .. tostring(log.pages[1])) + eq(log.writes, 1, "and it still saves") + eq(log.scriptVar, 1, "TRUE") +end + +-- `ld a, [wSaveFileExists] / and a / jr z, .erase`: no file, no question. +do + local log = runQuickSave({ exists = false, sameId = false }) + eq(log.asked, 0, "a first save is not asked to overwrite anything") + eq(log.writes, 1, "it just writes") + eq(log.scriptVar, 1, "TRUE") +end + +-- The one refusal the cart has no equivalent for: a mod vetoing save.write. +do + local log = runQuickSave({ exists = false, writeOk = false }) + eq(log.writes, 1, "the write was attempted") + eq(log.scriptVar, 0, "a refused write is the same FALSE as a refused prompt") +end + +-- ====================================== the World half of the two save hooks +do + local save = newSave() + local world, game = newWorld(save) + local wrote = 0 + game.writeSave = function() wrote = wrote + 1 return true end + check(world:writeSave() == true, "World:writeSave goes through Game2:writeSave") + eq(wrote, 1, "exactly once") + game.writeSave = function() return false end + check(world:writeSave() == false, "and a vetoed write reports false") + game.writeSave = nil + check(world:writeSave() == false, "with no game there is nothing to write") + + local hooks = world:specialHooks() + check(type(hooks.writeSave) == "function", "the writeSave hook is threaded") + check(type(hooks.saveFileState) == "function", "so is saveFileState") +end + +S.finish() diff --git a/tests/gen2_font_ui_test.lua b/tests/gen2_font_ui_test.lua index af499a5d..7e51e150 100644 --- a/tests/gen2_font_ui_test.lua +++ b/tests/gen2_font_ui_test.lua @@ -158,10 +158,17 @@ else if path:find("font%.png$") then images[path].getDimensions = function() return 128, 64 end end + -- One row per frame style, all eight (pokegold/gfx/font.asm:10). + if path:find("frames%.png$") then + images[path].getDimensions = function() return 48, 64 end + end return images[path] end Font.load({ font = fontDef }) require("src.render.Assets").image = realImage + -- currentFrame is a Font module-local that outlives a suite, and this file + -- is also dofile'd into tests/run_tests.lua after screens that cycle it. + Font.setFrame(1) -- Which sheet a code lands on, by the image drawCode reaches for. local drawn @@ -186,11 +193,18 @@ else "the swap runs to the last of the 25 tiles it loads") -- `jr LoadFrame` at the end of _LoadFontsBattleExtra: $79-$7e are the -- textbox frame on a battle-sheet screen too, so a box drawn there is the - -- same box as everywhere else. - for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr", - [0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do - eq(sheetOf(code), "font_extra.png", - ("LoadFrame keeps the %s border glyph off the battle sheet"):format(name)) + -- same box as everywhere else. LoadFrame reads the `Frames` label, not + -- FontExtra (pokegold/engine/gfx/load_font.asm:29-39). + -- A cache built before the extractor split Frames out has no imageFrames + -- row, and its $79-$7e fall back to the extra sheet's baked-in frame 1. + if not fontDef.imageFrames then + check(true, "cache predates the frames sheet (SKIP the border routing)") + else + for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr", + [0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do + eq(sheetOf(code), "frames.png", + ("LoadFrame keeps the %s border glyph off the battle sheet"):format(name)) + end end Font.useBattleExtra(false) love.graphics.draw = realDraw @@ -369,11 +383,16 @@ stopRecording() local dexLine = findPrint("№.") check(dexLine ~= nil, "the roster line places the No ligature") -check(dexLine and dexLine.battleExtra == true, - "with the battle sheet in the $60 slot, where that glyph lives") local idLine = findPrint("№/") -check(idLine ~= nil and idLine.battleExtra == true, - "and so does the trainer id line") +-- Which sheet a glyph resolves against needs the cache's real font rows. +if not fontDef then + check(true, "no Gold cache (SKIP the battle-sheet routing)") +else + check(dexLine and dexLine.battleExtra == true, + "with the battle sheet in the $60 slot, where that glyph lives") + check(idLine ~= nil and idLine.battleExtra == true, + "and so does the trainer id line") +end eq(Font.battleExtraActive(), false, "the sheet the caller had is put back when the screen is done") @@ -392,10 +411,14 @@ photo:draw() stopRecording() local photoDex = findPrint("№.") -check(photoDex ~= nil and photoDex.battleExtra == true, - "the photo card's No ligature resolves against the battle sheet") local photoId = findPrint("№") -check(photoId ~= nil and photoId.battleExtra == true, "and its does too") +if not fontDef then + check(true, "no Gold cache (SKIP the photo card's battle-sheet routing)") +else + check(photoDex ~= nil and photoDex.battleExtra == true, + "the photo card's No ligature resolves against the battle sheet") + check(photoId ~= nil and photoId.battleExtra == true, "and its does too") +end eq(Font.battleExtraActive(), false, "and the card puts the sheet back") S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 6b175588..8c0934b2 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -357,8 +357,9 @@ local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, --- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. -check("twenty-three rows", #OptionsMenu.ROWS, 23) +-- video mode, screen position, the mobile-gated touch three (buildRows), +-- MAX FPS and CANCEL. +check("twenty-four rows", #OptionsMenu.ROWS, 24) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") @@ -965,7 +966,9 @@ check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, "TILT") check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, "VIDEO MODE") -check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, +check("and SCREEN POS follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, + "SCREEN POS") +check("and TOUCH PAD follows that", OptionsMenu.ROWS[gbcfxIndex + 3].label, "TOUCH PAD") local videoRow = select(2, rowNamed("VIDEO MODE")) diff --git a/tests/gen2_reflect_overflow_test.lua b/tests/gen2_reflect_overflow_test.lua new file mode 100644 index 00000000..12fed752 --- /dev/null +++ b/tests/gen2_reflect_overflow_test.lua @@ -0,0 +1,183 @@ +-- TruncateHL_BC (../pokegold/engine/battle/effect_commands.asm:2625, +-- ../pokecrystal/engine/battle/effect_commands.asm:2614). +-- +-- luajit tests/gen2_reflect_overflow_test.lua +-- +-- ROM-free. The cart hands BattleCommand_DamageCalc one-byte stats, so a +-- 16-bit attack/defence pair is shifted right twice -- both together, so the +-- ratio survives -- and the low byte taken. Gold runs that pass exactly once +-- and keeps whatever the low byte then holds, so Reflect or Light Screen on a +-- 512+ defence pushes the doubled value to 1024 and the truncated byte wraps +-- to 0; DamageCalc's minimum-defence check turns that into 1 and the hit caps. +-- Crystal repeats the pass until both stats fit, which is the fix CT-10 names +-- reflectOverflow. Crystal kept the old arithmetic under LINK_COLOSSEUM; the +-- Gen 2 battle engine here has no link mode, so only the single-player halves +-- are pinned. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 reflect overflow") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local Damage = require("src.battle.gen2.Damage") + +local restore = GameVersion.get() + +-- One NORMAL physical hit with no type table, no STAB and the damage roll +-- pinned at 100%, so the only moving part is the truncated defence. +local function hit(version, defense, screen) + GameVersion.set(version) + return (Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, + defender = { defense = defense }, + screen = screen, variation = 100, + })) +end + +local function truncate(version, attack, defense) + GameVersion.set(version) + return Damage.truncateStats(attack, defense, + GameVersion.fixes().reflectOverflow == true) +end + +-- --------------------------------------------------------- the gate itself + +do + eq(GameVersion.fixes("gold").reflectOverflow, nil, + "Gold keeps the cart's single truncation pass") + eq(GameVersion.fixes("silver").reflectOverflow, nil, + "Silver keeps it too") + eq(GameVersion.fixes("crystal").reflectOverflow, true, + "Crystal is the version that loops") +end + +-- ------------------------------------------------- below the truncate line + +do + -- Both stats already fit in a byte, so TruncateHL_BC returns at once and + -- every ordinary battle is untouched by either arm. + local a, d = truncate("gold", 200, 100) + eq(a, 200, "an 8-bit attack passes through") + eq(d, 100, "an 8-bit defence passes through") + eq(hit("gold", 100, false), 90, "Gold, defence 100, no screen") + eq(hit("crystal", 100, false), 90, "Crystal agrees") + eq(hit("gold", 100, true), 46, "Gold, defence 100 doubled to 200") + eq(hit("crystal", 100, true), 46, "Crystal agrees") +end + +-- ----------------------------------------- one pass, which both games share + +do + -- 511 doubled is 1022; one >>2 gives 255, which fits, so Crystal's loop + -- never runs a second time and the two versions still agree. This is the + -- largest defence Reflect can double without the wrap. + local ga, gd = truncate("gold", 200, 511 * 2) + local ca, cd = truncate("crystal", 200, 511 * 2) + eq(gd, 255, "1022 >> 2 is 255, the last value that fits") + eq(ga, 50, "the attack is shifted with it, 200 >> 2") + eq(cd, 255, "Crystal's loop exits on the same pass") + eq(ca, 50, "and leaves the attack alone as well") + eq(hit("gold", 511, true), 10, "Gold, defence 511 doubled") + eq(hit("crystal", 511, true), 10, "Crystal, defence 511 doubled") +end + +do + -- A big defence with no screen cannot reach 1024 on its own (stats cap at + -- 999), so the gate must not move any unscreened hit. + for _, defense in ipairs({ 256, 400, 512, 700, 999 }) do + eq(hit("gold", defense, false), hit("crystal", defense, false), + ("defence %d without a screen is version-independent"):format(defense)) + end + eq(hit("gold", 999, false), 10, "defence 999 unscreened, both versions") +end + +-- ------------------------------------------------------------- the wrap + +do + -- 512 doubled is exactly 1024. Gold: 1024 >> 2 = 256, low byte 0, and + -- DamageCalc's `ld c, 1` turns that into a defence of 1. Crystal: a second + -- pass takes 256 -> 64 and the attack 50 -> 12. + local ga, gd = truncate("gold", 200, 1024) + eq(gd, 0, "Gold truncates 1024 to a defence byte of 0") + eq(ga, 50, "Gold's attack byte after the single pass") + local ca, cd = truncate("crystal", 200, 1024) + eq(cd, 64, "Crystal shifts again, 256 >> 2") + eq(ca, 12, "and the attack with it, 50 >> 2") + + -- 22 * 100 * 50 / 1 / 50 = 2200, capped to 997 + 2. + eq(hit("gold", 512, true), 999, "Gold's Reflect caps the hit at 999") + -- 22 * 100 * 12 / 64 / 50 = 8, + 2. + eq(hit("crystal", 512, true), 10, "Crystal's Reflect keeps the hit at 10") + check(hit("gold", 512, false) == 19, + "and the same defence unscreened is 19 on both") + check(hit("gold", 512, true) > hit("gold", 512, false), + "Gold's Reflect makes the defender take MORE damage") + check(hit("crystal", 512, true) < hit("crystal", 512, false), + "Crystal's Reflect halves it, as it should") +end + +do + -- The worst case the cart can build: a 999 defence doubled to 1998. + -- Gold: 1998 >> 2 = 499, low byte 243. Crystal: 499 >> 2 = 124, attack 12. + local ga, gd = truncate("gold", 200, 999 * 2) + eq(gd, 243, "Gold wraps 499 to 243") + eq(ga, 50, "Gold's attack byte") + local ca, cd = truncate("crystal", 200, 999 * 2) + eq(cd, 124, "Crystal shifts 499 to 124") + eq(ca, 12, "and the attack to 12") + eq(hit("gold", 999, true), 11, "Gold: Reflect on a 999 defence deals 11") + eq(hit("crystal", 999, true), 6, "Crystal: the same hit deals 6") + eq(hit("gold", 999, false), 10, "unscreened it is 10, so the screen HURTS") +end + +-- ------------------------------------------------ the minimum-1 arms + +do + -- `ld a, c / or b / jr nz / inc c` and its hl twin: a stat that shifts down + -- to zero is forced back to 1 before the next pass, on both versions. + local ga, gd = truncate("gold", 3, 2000) + eq(ga, 1, "Gold: an attack of 3 shifts to 0 and is forced to 1") + eq(gd, 244, "Gold: 2000 >> 2 = 500, low byte 244") + local ca, cd = truncate("crystal", 3, 2000) + eq(ca, 1, "Crystal: the forced 1 shifts to 0 and is forced again") + eq(cd, 125, "Crystal: 500 >> 2 = 125") +end + +-- ------------------------------------------------------------- the override + +do + -- The gate is readable per call, which is where a LINK_COLOSSEUM carve-out + -- would hang if the Gen 2 engine ever grows one. + GameVersion.set("crystal") + local bugged = Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, defender = { defense = 512 }, + screen = true, variation = 100, reflectOverflowFixed = false, + }) + eq(bugged, 999, "Crystal forced onto the bugged arm matches Gold") + GameVersion.set("gold") + local fixed = Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, defender = { defense = 512 }, + screen = true, variation = 100, reflectOverflowFixed = true, + }) + eq(fixed, 10, "Gold forced onto the fixed arm matches Crystal") +end + +-- ------------------------------------------------------- the 999 stat cap + +do + -- ApplyStatLevelMultiplier caps at MAX_STAT_VALUE before anything doubles + -- it (../pokecrystal/engine/battle/core.asm:6739), which is what bounds the + -- doubled defence at 1998 rather than letting +6 stages run past it. + eq(Damage.applyStage(999, 6), 999, "a +6 stage cannot pass 999") + eq(Damage.applyStage(250, 6), 999, "250 x4 is 1000, capped to 999") + eq(Damage.applyStage(249, 6), 996, "249 x4 stays below the cap") + eq(Damage.applyStage(1, -6), 1, "and a stat never reaches 0") +end + +GameVersion.set(restore) + +S.finish() diff --git a/tests/gen2_rom_text_test.lua b/tests/gen2_rom_text_test.lua index 8ffefcc5..73cf6d7a 100644 --- a/tests/gen2_rom_text_test.lua +++ b/tests/gen2_rom_text_test.lua @@ -33,7 +33,7 @@ end -- A label the list names but the symbol table cannot place would fail the -- import at the Dialogue stage rather than at generation time, so the pairing -- is asserted here instead. -for _, edition in ipairs({ "gold", "silver" }) do +for _, edition in ipairs({ "gold", "silver", "crystal" }) do local data = manifest("tools/rom_manifest_" .. edition .. ".json") local labels = (data.text or {}).labels or {} check((data.text or {}).labels ~= nil, @@ -64,7 +64,7 @@ for _, edition in ipairs({ "gold", "silver" }) do end end --- Both editions describe the same strings; only the addresses move. +-- Gold and Silver describe the same strings; only the addresses move. do local gold = (manifest("tools/rom_manifest_gold.json").text or {}).labels or {} local silver = @@ -75,6 +75,20 @@ do if silver[index] ~= label then mismatch = label; break end end eq(mismatch, nil, "and the same labels in the same order") + + local crystal = + (manifest("tools/rom_manifest_crystal.json").text or {}).labels or {} + check(#crystal > #gold, + ("Crystal names more labels than Gold (%d vs %d)"):format(#crystal, #gold)) + local named = {} + for _, label in ipairs(gold) do named[label] = true end + local extra = 0 + for _, label in ipairs(crystal) do + if not named[label] then extra = extra + 1 end + end + check(extra > 0, + ("and %d of them are Crystal-only, so it is not a Gold alias") + :format(extra)) end -- ---- the slots RomText fills ---------------------------------------------- diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua index 6493b63f..a2a3d5d4 100644 --- a/tests/gen2_save_test.lua +++ b/tests/gen2_save_test.lua @@ -117,16 +117,34 @@ check("a Gen 2 version already on the table is kept", Save.normalize({ version = "silver", player = {} }).version, "silver") check("and a Gen 1 one is replaced by the running edition", Save.normalize({ version = "red", player = {} }).version, "gold") +check("and Crystal is a Gen 2 version, so it keeps its own", + Save.normalize({ version = "crystal", player = {} }).version, "crystal") +-- "nonesuch" is the deliberate never-a-game token: "gold" stood here until +-- Gold shipped, "crystal" until Crystal did. This one never becomes a game. check("as is a version this engine does not have", - Save.normalize({ version = "crystal", player = {} }).version, "gold") + Save.normalize({ version = "nonesuch", player = {} }).version, "gold") check("party exists", type(sparse.party), "table") check("inventory exists", type(sparse.inventory), "table") check("pokedex seen exists", type(sparse.pokedex.seen), "table") check("phone book exists", type(sparse.phoneContacts), "table") check("play time exists", type(sparse.playTime), "table") check("name defaulted", sparse.player.name, "GOLD") +-- wPlayerGender is zeroed by _ResetWRAM and Gold never writes it, so a +-- normalized save has to come back with the field present rather than nil. +check("gender defaulted", sparse.player.gender, "male") +check("and a recorded gender is kept", + Save.normalize({ player = { gender = "female" } }).player.gender, "female") check("normalize rejects a non-table", Save.normalize("nope"), nil) +-- The blank-name fallback is table driven so Crystal can be a row rather than +-- a third arm of a two-way test (data/player_names.asm). +check("Gold's PlayerNameArray row", Save.defaultPlayerName("gold"), "GOLD") +check("Silver's", Save.defaultPlayerName("silver"), "SILVER") +check("Crystal's MalePlayerNameArray row", + Save.defaultPlayerName("crystal"), "CHRIS") +check("and an unknown edition falls back to Gold's", + Save.defaultPlayerName("nonesuch"), "GOLD") + -- Money and coins are clamped to their caps, and a negative is floored at 0. local rich = Save.normalize({ player = { money = 9999999, coins = 99999 } }) check("money capped", rich.player.money, Save.MAX_MONEY) diff --git a/tests/gen2_unown_chambers_test.lua b/tests/gen2_unown_chambers_test.lua new file mode 100644 index 00000000..609c99db --- /dev/null +++ b/tests/gen2_unown_chambers_test.lua @@ -0,0 +1,233 @@ +-- The four Ruins of Alph secret chambers: +-- ../pokecrystal/engine/events/unown_walls.asm:1 HoOhChamber, :13 +-- OmanyteChamber, :54 SpecialAerodactylChamber, :81 SpecialKabutoChamber. +-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_chambers_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 unown chambers") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local UnownWords = require("src.world.gen2.UnownWords") + +local function loadTable(path) + local chunk = loadfile(path) + return chunk and chunk() or nil +end + +local function fakeVm(opts) + opts = opts or {} + local pack = opts.pack or {} + return { + scriptVar = 0, + events = opts.events or Events.new(), + specials = { + party = function() return opts.party or {} end, + itemIndex = function(id) return id == "WATER_STONE" and 24 or nil end, + hasItem = function(index) return pack[index] == true end, + }, + } +end + +local WALLS = { + { "HO_OH", 806 }, { "KABUTO", 807 }, + { "OMANYTE", 808 }, { "AERODACTYL", 809 }, +} + +-- ../pokecrystal/constants/event_flags.asm:486-489 +do + for _, row in ipairs(WALLS) do + eq(UnownWords.WALL_OPENED[row[1]], row[2], + "EVENT_WALL_OPENED_IN_" .. row[1] .. "_CHAMBER is flag " .. row[2]) + end + local events = Events.new() + check(not UnownWords.wallOpened(events, "OMANYTE"), "a fresh save has no wall open") + check(UnownWords.openWall(events, "OMANYTE"), "the first SET_FLAG reports the change") + check(UnownWords.wallOpened(events, "OMANYTE"), "and CHECK_FLAG reads it back") + check(not UnownWords.openWall(events, "OMANYTE"), "a second one is a no-op") + check(not UnownWords.wallOpened(events, "KABUTO"), + "and it did not open any of the other three") + check(not UnownWords.openWall(nil, "OMANYTE"), "no bitfield, no write") + check(not UnownWords.openWall(Events.new(), "BETA"), + "and a chamber the cart does not have has no flag to set") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:2-5 HoOhChamber +do + check(UnownWords.leadIsHoOh({ { species = "HO_OH" } }), + "Ho-Oh in the first slot is what the routine asks for") + check(not UnownWords.leadIsHoOh({ { species = "LUGIA" }, { species = "HO_OH" } }), + "Ho-Oh in the second slot is not: it reads wPartySpecies[0] only") + check(not UnownWords.leadIsHoOh({ { species = "HO_OH", isEgg = true } }), + "an egg holds EGG in wPartySpecies, not the hatchling's species") + check(not UnownWords.leadIsHoOh({}), "and an empty party is not Ho-Oh") + + local vm = fakeVm({ party = { { species = "HO_OH" } } }) + Specials.ALL.HoOhChamber(vm) + check(UnownWords.wallOpened(vm.events, "HO_OH"), "the handler opens the wall") + eq(vm.scriptVar, 0, "and leaves wScriptVar alone: the scene's own checkevent reads the flag") + + vm = fakeVm({ party = { { species = "SUICUNE" } } }) + Specials.ALL.HoOhChamber(vm) + check(not UnownWords.wallOpened(vm.events, "HO_OH"), + "any other lead leaves it shut") + + vm = fakeVm({ party = { { species = "HO_OH" } } }) + Specials.ALL.HoOhChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "and it never touches the Omanyte bit") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:28-43 OmanyteChamber +do + eq(UnownWords.waterStoneSlot({ { item = "WATER_STONE" } }), 1, + "one held Water Stone is found") + eq(UnownWords.waterStoneSlot( + { { item = "WATER_STONE" }, {}, { item = "WATER_STONE" } }), 3, + "and with two, the backwards walk stops at the LAST slot") + check(UnownWords.waterStoneSlot({ { item = "FIRE_STONE" }, {} }) == nil, + "no stone, no slot") + check(UnownWords.waterStoneSlot(nil) == nil, "and no party is no slot") + + local vm = fakeVm({ pack = { [24] = true } }) + Specials.ALL.OmanyteChamber(vm) + check(UnownWords.wallOpened(vm.events, "OMANYTE"), + "CheckItem on the pack alone opens it") + + vm = fakeVm({ party = { {}, { item = "WATER_STONE" } } }) + Specials.ALL.OmanyteChamber(vm) + check(UnownWords.wallOpened(vm.events, "OMANYTE"), + "so does a Water Stone held by a party mon") + + vm = fakeVm({ party = { { item = "FIRE_STONE" } } }) + Specials.ALL.OmanyteChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "neither in the pack nor held leaves it shut") + + vm = fakeVm({}) + Specials.ALL.OmanyteChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "and an empty party with an empty pack does nothing") + + -- ../pokecrystal/engine/events/unown_walls.asm:14-20 + local seen = 0 + vm = fakeVm({}) + vm.specials.party = function() seen = seen + 1 return {} end + UnownWords.openWall(vm.events, "OMANYTE") + Specials.ALL.OmanyteChamber(vm) + eq(seen, 0, "an already-open wall returns before the party is walked") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:54, :81, both reached from a +-- field move rather than from a `special`. +do + local events = Events.new() + check(not UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"), + "Flash in the wrong chamber returns no carry") + check(not UnownWords.wallOpened(events, "AERODACTYL"), "and opens nothing") + check(not UnownWords.aerodactylChamber(events, "DARK_CAVE_VIOLET_ENTRANCE"), + "nor does Flash in an ordinary dark cave") + check(UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_AERODACTYL_CHAMBER"), + "in its own chamber it returns the carry FlashFunction jumps on") + check(UnownWords.wallOpened(events, "AERODACTYL"), "and sets the bit") + + events = Events.new() + check(not UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_OMANYTE_CHAMBER"), + "an escape rope elsewhere does nothing") + check(not UnownWords.wallOpened(events, "KABUTO"), "and leaves the bit clear") + check(UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"), + "in the Kabuto chamber it fires") + check(UnownWords.wallOpened(events, "KABUTO"), "and sets its own bit") + check(not UnownWords.wallOpened(events, "AERODACTYL"), + "with the Aerodactyl bit untouched") +end + +-- data/events/special_pointers.asm:148 OmanyteChamber, :157 HoOhChamber +do + for _, name in ipairs({ "OmanyteChamber", "HoOhChamber" }) do + check(type(Specials.ALL[name]) == "function", name .. " is a handler") + check(Specials.STUBS[name] == nil, name .. " is no longer a stub") + eq(Specials.HANDLER_SOURCE[name], "specials/crystal_story.lua", + "owned by this unit's module") + check(Specials.SUPERSEDED_STUBS[name] ~= nil, + "and its stub reason moved to SUPERSEDED_STUBS") + end +end + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local maps = loadTable(cache .. "/data/generated/maps.lua") +local scripts = loadTable(cache .. "/data/generated/scripts.lua") +local consts = loadTable(cache .. "/data/generated/constants.lua") + +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:22-24, the +-- MAPCALLBACK_TILES callback that re-derives the flag numbers from the cart. +local specialId = {} +for id, name in pairs((consts or {}).specialOrder or {}) do + specialId[name] = id - 1 +end + +if not (maps and scripts and consts) then + check(true, "no cache: the chamber scripts are not walked (SKIP)") +elseif not specialId.OmanyteChamber then + -- data/events/special_pointers.asm:124 `; Crystal only` + check(true, "cache is not a Crystal one (SKIP)") +else + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10 and + -- RuinsOfAlphHoOhChamber.asm:10; the other two chambers have no `special`. + local EXPECTED = { + HO_OH = "HoOhChamber", + OMANYTE = "OmanyteChamber", + KABUTO = false, + AERODACTYL = false, + } + for chamber, wantSpecial in pairs(EXPECTED) do + local mapId = UnownWords.CHAMBER_MAPS[chamber] + local def = maps[mapId] + if not def then + check(false, mapId .. " is in the cache") + else + local callback = (def.callbacks or {})[1] + local rows = callback and scripts[callback.scriptKey] + local first = rows and rows[1] + eq(callback and callback.callback, "MAPCALLBACK_TILES", + mapId .. " has its hidden-doors callback") + eq(first and first.op, "checkevent", "which opens on a checkevent") + eq(first and first.event, UnownWords.WALL_OPENED[chamber], + "of the very flag " .. chamber .. "'s routine writes") + + local scene + for _, entry in pairs(def.sceneScripts or {}) do + if (entry.sceneId or 0) == 0 then scene = entry end + end + local sceneRows = scene and scripts[scene.scriptKey] or {} + local got + for _, row in ipairs(sceneRows) do + if row.op == "special" then got = row.id end + end + if wantSpecial then + eq(got, specialId[wantSpecial], + mapId .. "'s check-wall scene runs " .. wantSpecial) + else + check(got == nil, + mapId .. "'s check-wall scene runs no special: its trigger is a field move") + end + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:11 + local read + for _, row in ipairs(sceneRows) do + if row.op == "checkevent" then read = row.event end + end + eq(read, UnownWords.WALL_OPENED[chamber], + "and it reads the same flag straight afterwards") + end + end +end + +S.finish() diff --git a/tests/gen2_unown_printer_test.lua b/tests/gen2_unown_printer_test.lua index a6e50fe2..f55d36ae 100644 --- a/tests/gen2_unown_printer_test.lua +++ b/tests/gen2_unown_printer_test.lua @@ -285,8 +285,9 @@ end check(Specials.HANDLERS.UnownPrinter ~= nil, "UnownPrinter has a handler: the viewer half needs no printer") eq(Specials.STUBS.UnownPrinter, nil, "and is not also stubbed") -check(Specials.STUBS.PrintDiploma ~= nil, - "PrintDiploma, which is nothing but a print, still is") +check(Specials.HANDLERS.PrintDiploma ~= nil, + "PrintDiploma took the same route: the diploma page is drawn on the " + .. "cartridge and only the send wanted a printer") -- The hook a Specials handler reaches for lives in World:specialHooks, NOT in -- the table handed to Vm.new -- a hook registered in the wrong one is diff --git a/tests/gen2_unown_words_test.lua b/tests/gen2_unown_words_test.lua new file mode 100644 index 00000000..c58f4ae7 --- /dev/null +++ b/tests/gen2_unown_words_test.lua @@ -0,0 +1,310 @@ +-- The Ruins of Alph wall words: engine/events/unown_walls.asm:102 +-- DisplayUnownWords and data/events/unown_walls.asm:7 UnownWalls. +-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_words_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 unown words") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Json = require("src.link.Json") +local UnownWords = require("src.world.gen2.UnownWords") + +local function readFile(path, mode) + local f = io.open(path, mode or "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local function pngSize(body) + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be32(body, 17), be32(body, 21) +end + +local function loadTable(path) + local chunk = loadfile(path) + return chunk and chunk() or nil +end + +-- engine/events/unown_walls.asm:201 .ConvertChar +do + local tl, tr, bl, br = UnownWords.square(0x00) -- 'A' + eq(tl, 0x80, "A's top-left is bank 1 tile $00") + eq(tr, 0x81, "top-right is the next tile") + eq(bl, 0x90, "bottom-left is a 16-tile row down") + eq(br, 0x91, "and bottom-right beside it") + eq((UnownWords.square(0x0e)), 0x8e, "H is bank 1 tile $0e") + eq((UnownWords.square(0x20)), 0xa0, "I steps a whole row: bank 1 tile $20") + eq((UnownWords.square(0x4e)), 0xce, "X is the last computed letter") + local y1, y2, y3, y4 = UnownWords.square(0x60) + eq(y1 * 0x1000000 + y2 * 0x10000 + y3 * 0x100 + y4, + 0x5b5c4d5d, "Y is .YChar's four bank 0 tiles") + local z1, z2, z3, z4 = UnownWords.square(0x62) + eq(z1 * 0x1000000 + z2 * 0x10000 + z3 * 0x100 + z4, + 0x4e4f5e5f, "Z is .ZChar's") + local d1, d2, d3, d4 = UnownWords.square(0x64) + eq(d1 * 0x1000000 + d2 * 0x10000 + d3 * 0x100 + d4, + 0x02030302, "and the dash is .DashChar's") +end + +-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls, the n = 6 row. +do + local escape = { x1 = 3, y1 = 4, x2 = 16, y2 = 9, + chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } } + local bx, by, bw, bh = UnownWords.boxRect(escape) + eq(bx, 3, "the box starts at column 3") + eq(by, 4, "row 4") + eq(bw, 14, "and spans the coordinate pair inclusive: 14 columns") + eq(bh, 6, "by 6 rows") + local ox, oy = UnownWords.origin(escape) + eq(ox, 4, "the first square is one column inside the frame") + eq(oy, 6, "and two rows down") + local squares = UnownWords.layout(escape) + eq(#squares, 6, "ESCAPE is six squares") + eq(squares[1].tx, 4, "the E starts at column 4") + eq(squares[2].tx, 6, "and every letter is two tiles wide") + eq(squares[6].tx, 14, "so the last one ends on the frame's inner edge") + eq(squares[1].ty, 6, "all of them on the same row") + eq(squares[4].tl, 0x80, "the A in ESCAPE is bank 1 tile $00") +end + +-- constants/charmap.asm:424 the `unown` charmap. +do + local body = readFile("tools/rom_manifest_crystal.json", "r") + if not body then + check(true, "no tools/rom_manifest_crystal.json (SKIP)") + else + local manifest = Json.decode(body) + local map = manifest.unownCharmap + if not map then + check(false, "the Crystal manifest carries unownCharmap") + else + eq(map["0"], "A", "$00 is A") + eq(map["2"], "B", "$02 is B, two tiles along") + eq(map["14"], "H", "$0e is H, the last of the first row") + eq(map["32"], "I", "$20 is I: the row step is $10 every eight letters") + eq(map["78"], "X", "$4e is X") + eq(map["96"], "Y", "$60 is Y") + eq(map["98"], "Z", "$62 is Z") + eq(map["100"], "-", "$64 is the dash") + eq(map["255"], "@", "and $ff terminates a word") + local count = 0 + for _ in pairs(map) do count = count + 1 end + eq(count, 28, "27 printable characters plus the terminator") + end + -- constants/charmap.asm:5 -- $00 is in the main charmap. + local main = manifest.charmap or {} + check(main["0"] ~= "A", "the main charmap did not absorb the Unown rows") + eq(manifest.symbols.UnownWalls[1], 0x22, + "UnownWalls is in the bank pokecrystal.sym says") + eq(manifest.symbols.UnownWalls[2], 0x6ebc, "and at its address") + eq(manifest.symbols.MenuHeaders_UnownWalls[2], 0x6ed5, + "with the menu headers immediately after it") + end +end + +do + for _, edition in ipairs({ "gold", "silver" }) do + local body = readFile("tools/rom_manifest_" .. edition .. ".json", "r") + if not body then + check(true, "no " .. edition .. " manifest (SKIP)") + else + local manifest = Json.decode(body) + check(manifest.unownCharmap == nil, + edition .. " has no Unown charmap: the block is Crystal only") + check(manifest.symbols.UnownWalls == nil, + edition .. " has no UnownWalls either") + end + end +end + +-- constants/charmap.asm:423,433 the two `pushc` blocks the main parser skips. +do + local source = readFile("tools/make_gold_manifest.py", "r") + if not source then + check(true, "no tools/make_gold_manifest.py (SKIP)") + else + check(source:find('if re.match(r"(pushc|newcharmap)\\b", stripped):', + 1, true) ~= nil, "the main charmap parser still stops at newcharmap") + end + local crystal = readFile("tools/make_crystal_manifest.py", "r") + if not crystal then + check(true, "no tools/make_crystal_manifest.py (SKIP)") + else + check(crystal:find('data["unownCharmap"] = unown_charmap(', 1, true) ~= nil, + "and the Unown one is generated beside it, under its own key") + check(crystal:find("PRINTABLE_UNOWN", 1, true) == nil, + "with the letter set read out of charmap.asm, not copied here") + end +end + +-- home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes. +do + local source = assert(readFile("src/import/RomExtractorGen2.lua", "r")) + check(source:find("local function crystalTilesetSheet(pixels)", 1, true) + ~= nil, "the Crystal sheet lays both VRAM banks out at their tile ids") + check(source:find("if twoBank then pixels = crystalTilesetSheet(pixels) end", + 1, true) ~= nil, "and extractTilesets uses it") + check(source:find("local CRYSTAL_PAL_MAP_BYTES = 112", 1, true) ~= nil, + "the PalMap read covers the bank 1 rows too") + check(source:find("out.unownWalls = walls", 1, true) ~= nil, + "and readEventTables emits the wall words") +end + +-- gfx/tilesets/ruins_of_alph.png, whose bottom half is the Unown alphabet. +do + local png = readFile( + "../pokecrystal/gfx/tilesets/ruins_of_alph.png") + if not png then + check(true, "no ../pokecrystal: the tileset's shape is not pinned (SKIP)") + else + local w, h = pngSize(png) + eq(w, 128, "pret's ruins_of_alph.png is 16 tiles across") + eq(h, 96, "and 12 down: $60 tiles per VRAM bank, two banks") + end +end + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local events = loadTable(cache .. "/data/generated/events.lua") +local tilesets = loadTable(cache .. "/data/generated/tilesets.lua") + +-- data/events/unown_walls.asm:2-5 +local EXPECTED = { + { word = "ESCAPE", x1 = 3, y1 = 4, x2 = 16, y2 = 9 }, + { word = "LIGHT", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, + { word = "WATER", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, + { word = "HO-OH", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, +} + +if not events then + check(true, "no Crystal cache: the wall words are not read (SKIP)") +elseif not events.unownWalls then + check(true, "cache predates the wall words: re-import Crystal (SKIP)") +else + local walls = events.unownWalls + eq(#walls, 4, "NUM_UNOWN_WALLS rows came out") + for index, want in ipairs(EXPECTED) do + local got = walls[index] or {} + eq(got.word, want.word, want.word .. " decoded through the Unown charmap") + eq(got.id, index - 1, "at the UNOWNWORDS_* value the setval uses") + eq(#(got.chars or {}), #want.word, "with one character byte per letter") + eq(got.x1, want.x1, want.word .. "'s box left edge") + eq(got.y1, want.y1, "top edge") + eq(got.x2, want.x2, "right edge") + eq(got.y2, want.y2, "bottom edge") + -- data/events/unown_walls.asm:20 MENU_BACKUP_TILES + eq(got.flags, 0x40, "and MENU_BACKUP_TILES set") + -- data/events/unown_walls.asm:21 `menu_coords 9 - n, 4, 10 + n, 9` + local _, _, bw = UnownWords.boxRect(got) + eq(bw - 2, #want.word * 2, "the frame is 2 columns per letter wide") + end + -- engine/events/unown_walls.asm:249 .DashChar + local dash = UnownWords.layout(walls[4])[3] + eq(dash.tl, 0x02, "HO-OH's dash comes out of .DashChar, not the alphabet") +end + +if not tilesets then + check(true, "no Crystal cache: the tileset sheet is not read (SKIP)") +else + local roa = tilesets.TILESET_RUINS_OF_ALPH + if not roa then + check(false, "the Ruins of Alph tileset is in the cache") + elseif roa.imageHeight ~= 128 then + check(true, "cache predates the two-bank sheet: re-import Crystal (SKIP)") + else + eq(roa.imageWidth, 128, "the sheet is 16 tiles across") + eq(roa.imageHeight, 128, "and 16 down: 256 tile ids, both VRAM banks") + eq(roa.tilesPerRow, 16, "so a tile id indexes it directly") + eq(#roa.tilePalettes, 224, "the PalMap covers every id the blocks use") + local png = readFile(cache .. "/assets/generated/tilesets/ruins_of_alph.png") + if not png then + check(false, "the sheet PNG is actually in the cache") + else + local w, h = pngSize(png) + eq(w, 128, "the written PNG is 128 wide") + eq(h, 128, "and 128 tall") + end + -- constants/tileset_constants.asm:34-38, the five Crystal-only tilesets. + for _, name in ipairs({ "TILESET_KABUTO_WORD_ROOM", + "TILESET_OMANYTE_WORD_ROOM", "TILESET_AERODACTYL_WORD_ROOM", + "TILESET_HO_OH_WORD_ROOM", "TILESET_BETA_WORD_ROOM" }) do + local set = tilesets[name] + if not set then + check(false, name .. " extracted") + else + eq(set.imageHeight, 128, name .. " is a two-bank sheet") + -- engine/tilesets/map_palettes.asm:40 -- bit 7 is the VRAM bank. + local high = 0 + for _, block in ipairs(set.blocks) do + for _, tile in ipairs(block) do + if tile >= 0x80 and tile < 0xe0 then high = high + 1 end + end + end + check(high > 0, name .. " really does draw out of bank 1") + end + end + end +end + +-- data/events/special_pointers.asm:151 add_special DisplayUnownWords +do + local Specials = require("src.script.gen2.Specials") + check(type(Specials.ALL.DisplayUnownWords) == "function", + "DisplayUnownWords is a handler and not a stub") + eq(Specials.HANDLER_SOURCE.DisplayUnownWords, + "specials/unown_words.lua", "owned by this unit's module") +end + +do + local maps = loadTable(cache .. "/data/generated/maps.lua") + local scripts = loadTable(cache .. "/data/generated/scripts.lua") + local consts = loadTable(cache .. "/data/generated/constants.lua") + if not (maps and scripts and consts and events and events.unownWalls) then + check(true, "no Crystal cache: the chambers are not walked (SKIP)") + else + local index + for id, name in pairs(consts.specialOrder or {}) do + if name == "DisplayUnownWords" then index = id - 1 end + end + check(index ~= nil, "the cache's specialOrder names DisplayUnownWords") + local chambers = { + RUINS_OF_ALPH_KABUTO_CHAMBER = 0, + RUINS_OF_ALPH_AERODACTYL_CHAMBER = 1, + RUINS_OF_ALPH_OMANYTE_CHAMBER = 2, + RUINS_OF_ALPH_HO_OH_CHAMBER = 3, + } + for mapId, want in pairs(chambers) do + local def = maps[mapId] + local walls = 0 + for _, bg in ipairs((def or {}).bgEvents or {}) do + local rows = bg.scriptKey and scripts[bg.scriptKey] + local pending + for _, row in ipairs(rows or {}) do + if row.op == "setval" then + pending = row.value or (row.args and row.args[1]) + elseif row.op == "special" and row.id == index then + walls = walls + 1 + eq(pending, want, mapId .. " asks for its own word") + check(UnownWords.wallFor( + { gen2EventTables = events }, pending) ~= nil, + "and that value indexes a real wall row") + end + end + end + eq(walls, 2, mapId .. " has both of its wall patterns") + end + end +end + +S.finish() diff --git a/tests/gen2_version_fixes_test.lua b/tests/gen2_version_fixes_test.lua new file mode 100644 index 00000000..6a5d5146 --- /dev/null +++ b/tests/gen2_version_fixes_test.lua @@ -0,0 +1,231 @@ +-- The faithfulness convention: Gold and Silver keep their original cart bugs +-- wherever the bug is not hardware-dependent, and Crystal gets the fixes +-- Crystal shipped. GameVersion.fixes(id) names the FIX, so an absent table +-- reads as "bugged like the cart". +-- luajit tests/gen2_version_fixes_test.lua +-- +-- Four of pokegold's seven documented bugs are engine behaviour this port can +-- express. This file pins the two that are reachable from Lua state: +-- +-- luckyNumberBoxes the Lucky Number Show's loop bound +-- halloffame the first-save PC corruption, which is NOT reachable +-- here and is pinned as such rather than emulated +-- +-- surfOntoNpc lives with the field-move unit and reflectOverflow with the +-- battle unit; only the flag names are asserted here. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 version fixes") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GameVersion = require("src.core.GameVersion") +local HallOfFame = require("src.core.gen2.HallOfFame") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +local priorVersion = GameVersion.get() + +-- ---- CT-10: the API shape ------------------------------------------------- + +eq(type(GameVersion.fixes), "function", "GameVersion.fixes is an accessor") +eq(type(GameVersion.fixes("crystal")), "table", "and it returns a table") + +for _, id in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + eq(next(GameVersion.fixes(id)), nil, id .. " fixes nothing") +end +eq(next(GameVersion.fixes("nonesuch")), nil, "an unknown id reads as {}") + +for _, name in ipairs({ "luckyNumberBoxes", "surfOntoNpc", "reflectOverflow" }) do + eq(GameVersion.fixes("crystal")[name], true, "crystal fixes " .. name) +end + +do + local count = 0 + for _ in pairs(GameVersion.fixes("crystal")) do count = count + 1 end + eq(count, 3, "and carries no fix name a consumer was not told about") +end + +GameVersion.set("gold") +eq(next(GameVersion.fixes()), nil, "the active version is the default subject") +GameVersion.set("crystal") +eq(GameVersion.fixes().luckyNumberBoxes, true, "which follows GameVersion.set") + +-- Gold's row must stay bug-shaped even if someone later hangs data off it. +GameVersion.set("gold") +check(GameVersion.info("gold").fixes == nil, "the gold row has no fixes table") +check(GameVersion.info("silver").fixes == nil, "nor does silver") + +-- ---- the Lucky Number Show, boxes 10-14 ----------------------------------- +-- +-- pokegold/engine/events/lucky_number.asm:100-102 bounds .BoxesLoop with +-- NUM_BOXES_JP (9, pokegold/constants/pokemon_data_constants.asm:123) where +-- pokecrystal/engine/events/lucky_number.asm:99 uses NUM_BOXES (14). The OPEN +-- box is walked out of sBox before that loop starts and the loop then skips +-- it, so the current box is searched on both carts whatever its number. + +local function boxSet(order) + local seen = {} + for _, index in ipairs(order) do seen[index] = true end + return seen +end + +GameVersion.set("gold") +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 1 }) + eq(#order, 9, "Gold walks nine boxes") + eq(order[1], 1, "starting with the open one") + local seen = boxSet(order) + for index = 1, 9 do check(seen[index], "gold searches box " .. index) end + for index = 10, 14 do + check(not seen[index], "gold skips box " .. index .. " (the cart bug)") + end +end + +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 12 }) + eq(order[1], 12, "the open box is searched first even at 12") + eq(#order, 10, "on top of the nine .BoxesLoop reaches") + check(boxSet(order)[12], "so box 12 is not skipped while it is open") +end + +GameVersion.set("crystal") +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 1 }) + eq(#order, 14, "Crystal walks all fourteen") + local seen = boxSet(order) + for index = 1, 14 do check(seen[index], "crystal searches box " .. index) end +end + +do + local order = Specials.luckyNumberBoxOrder({}) + eq(order[1], 1, "a save with no currentBox opens box 1") + eq(#order, 14, "and still walks the full set") +end + +-- The handler itself, over the same fixture on both editions. The party mon +-- shares three trailing digits (second prize, wScriptVar 2); the mon in box 12 +-- is an exact match (first prize, wScriptVar 1), and the BEST match wins. +local function luckyVm(record) + return { + specials = { + save = function() return record end, + party = function() return record.party end, + monName = function(species) return species end, + }, + setStringBuffer = function(self, value) self.stringBuffer = value end, + } +end + +local function luckyFixture(boxIndex, currentBox) + local record = { + luckyNumber = 12345, + currentBox = currentBox or 1, + party = { { species = "CHIKORITA", otId = 99345 } }, + boxes = {}, + } + record.boxes[boxIndex] = { { species = "MAGIKARP", otId = 12345 } } + return record +end + +GameVersion.set("gold") +do + local vm = luckyVm(luckyFixture(3)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "Gold finds an exact match in box 3") + check(vm.luckyNumberInBox, "and reports it came from a box") +end + +do + local vm = luckyVm(luckyFixture(12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 2, "Gold never sees the same mon in box 12") + check(not vm.luckyNumberInBox, "so the party's partial match wins instead") +end + +do + local vm = luckyVm(luckyFixture(12, 12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "unless box 12 is the box the PC has open") +end + +GameVersion.set("crystal") +do + local vm = luckyVm(luckyFixture(12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "Crystal finds box 12 without opening it") + check(vm.luckyNumberInBox, "and reports it came from a box") +end + +do + local vm = luckyVm(luckyFixture(14)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "and box 14, the last one") +end + +-- ---- the Hall of Fame, first-save PC corruption --------------------------- +-- +-- pokegold/engine/events/halloffame.asm:17 is the bug marker; Crystal farcalls +-- HallOfFame_InitSaveIfNeeded there (pokecrystal/engine/menus/save.asm:470-475) +-- to run ErasePreviousSave over uninitialised SRAM. This port has no SRAM, so +-- the induction is pinned as harmless here rather than emulated. + +GameVersion.set("gold") +do + local save = Save.newGame({ name = "GOLD", gender = "male" }) + save.boxes[7] = { { species = "SUDOWOODO", level = 20, otId = 111 } } + save.boxNames[7] = "TREES" + save.currentBox = 7 + eq(HallOfFame.count(save), 0, "a fresh save has never been inducted") + check(not HallOfFame.hasEntered(save), "and carries no status flag") + + local party = { { species = "TYPHLOSION", level = 55, otId = 222, + nickname = "TYPHLO" } } + local entry, wasEntered = HallOfFame.induct(save, party) + check(entry ~= nil, "induction on a never-saved file returns a row") + check(not wasEntered, "and reports the player was not already a champion") + eq(HallOfFame.count(save), 1, "the win counter reaches one") + eq(#save.boxes[7], 1, "box 7 still holds its mon") + eq(save.boxes[7][1].species, "SUDOWOODO", "unchanged") + eq(save.boxNames[7], "TREES", "its name survives too") + eq(save.currentBox, 7, "and the open box is still open") + for index = 1, Save.NUM_BOXES do + local box = save.boxes[index] + check(box == nil or type(box) == "table", + "box " .. index .. " is a table or absent, never garbage") + end +end + +-- ---- the Coin Case terminator --------------------------------------------- +-- +-- pokegold/data/text/common_3.asm:341 ends _CoinCaseCountText with `done` ($57) +-- where pokecrystal/data/text/common_3.asm:1308 uses `text_end`, and +-- pokegold/home/text.asm:590-593 stops only on TX_END. The port decodes the +-- stream once at import (decodeGen2Text breaks on $57), so the cache is the +-- evidence that nothing runs past the string. + +local cache = os.getenv("GOLD_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local textPath = cache .. "/data/generated/rom_text.lua" +local tf = io.open(textPath, "r") +if not tf then + check(true, "rom_text.lua absent : no gold cache (SKIP cache facts)") +else + tf:close() + local romText = assert(loadfile(textPath))() + eq(romText._CoinCaseCountText, "Coins:\n{NUM}", + "the `done` terminator still ends the decoded string") + check(not tostring(romText._CoinCaseCountText or ""):find("Raise the PP"), + "and nothing from the next label runs into it") +end + +GameVersion.set(priorVersion) + +S.finish() diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index d0e3a6b3..b1b92761 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -2357,9 +2357,11 @@ check(Specials.STUBS.PhotoStudio == nil, "PhotoStudio is a HANDLER now: the conversation and the portrait card exist") check(Specials.HANDLERS.PhotoStudio ~= nil, "and it is the one that special dispatch resolves to") -check(Specials.STUBS.PrintDiploma ~= nil, - "PrintDiploma stays stubbed -- it is nothing but the print, with no screen " - .. "half of its own") +check(Specials.STUBS.PrintDiploma == nil + and Specials.HANDLERS.PrintDiploma ~= nil, + "PrintDiploma is a handler now: _PrintDiploma opens on the very page " + .. "`special Diploma` shows (engine/printer/printer.asm:382) and only the " + .. "two SendScreenToPrinter passes wanted a printer") check(Specials.STUBS.UnownPrinter == nil and Specials.HANDLERS.UnownPrinter ~= nil, "UnownPrinter is a handler for the same reason this one is: the stamp " diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index dc82fa8b..29a81a83 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -286,7 +286,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", - "faithfulRes", "fpsCap", + "faithfulRes", "screenPos", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) diff --git a/tests/parity_hpcolor_og_palette.lua b/tests/parity_hpcolor_og_palette.lua index 7dc9d72a..faae668b 100644 --- a/tests/parity_hpcolor_og_palette.lua +++ b/tests/parity_hpcolor_og_palette.lua @@ -48,7 +48,8 @@ end local ZONE0 = { { "red", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, { "blue", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, - { "yellow", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, + -- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR + { "yellow", "gbc", { 255, 255, 247 }, { 255, 255, 156 }, { 0, 173, 0 }, { 49, 49, 49 } }, { "red", "ogred", { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } }, { "blue", "ogred", { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 } }, { "yellow", "ogred", { 255, 255, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 25, 25, 25 } }, @@ -218,8 +219,9 @@ local ANIM = { { "blue", "ogred", { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } }, { "red", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, { "blue", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, - { "yellow", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, - { "yellow", "ogred", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, + -- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR + { "yellow", "gbc", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } }, + { "yellow", "ogred", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } }, } for _, c in ipairs(ANIM) do diff --git a/tests/parity_text_markers.lua b/tests/parity_text_markers.lua index c6cdbd0b..7edd5c92 100644 --- a/tests/parity_text_markers.lua +++ b/tests/parity_text_markers.lua @@ -73,14 +73,14 @@ end -- === #250: the CATERPIE / WEEDLE description === -- pokered/text/ViridianCity.asm declares this one without the leading --- underscore the extractor keys on, so it is not in data/generated/text.lua --- and the port carries a literal. That makes the literal load-bearing. +-- underscore, and the extractor now keys on both spellings, so the port's +-- literal has to keep agreeing with what the cart actually says. do - check(Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil - and Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil, - "the description really is absent from generated text (hence a literal)") - local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" + local extracted = Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText + or Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText + if extracted then eq(extracted, desc, "the literal still matches the cart") end + local pages = assertPlayable("caterpillar description", desc) eq(#pages, 2, "para breaks the description into two pages (#250)") eq(#pages[1], 3, "page 1 is text + line + cont") diff --git a/tests/rom_importer_last_version_test.lua b/tests/rom_importer_last_version_test.lua index c528be2f..7022ee7e 100644 --- a/tests/rom_importer_last_version_test.lua +++ b/tests/rom_importer_last_version_test.lua @@ -76,11 +76,12 @@ eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version") LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this -- A junk value in options.lua (hand-edited file, a build that knew other --- versions) must not select a tab that does not exist. "gold" used to be --- the stand-in for this (Gen 2's Gold/Silver support was still unwritten); --- now that Gold is a real version, "crystal" is the still-unknown one. +-- versions) must not select a tab that does not exist. Real version ids +-- stood here twice -- "gold", then "crystal" -- and each had to be swapped +-- out the day that game shipped, so this is "nonesuch": a deliberate +-- never-a-game token, not a version anyone is waiting on. local opts = SaveData.loadOptions() -opts.lastVersion = "crystal" +opts.lastVersion = "nonesuch" SaveData.saveOptions(opts) local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } }) ri5:_applyLastVersionTab() diff --git a/tests/run_gen2.lua b/tests/run_gen2.lua new file mode 100644 index 00000000..f8a8966e --- /dev/null +++ b/tests/run_gen2.lua @@ -0,0 +1,50 @@ +-- T2 Gen 2 tier: tests/gen2_*.lua, tests/crystal_*.lua and the LZ3 decoder, one +-- process per suite. ROM-free, so it runs with no data/generated/ and no cache. +-- luajit tests/run_gen2.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local FsIo = require("tests.fs_io") + +local PREFIXES = { "gen2_", "crystal_" } +local EXTRA = { "tests/rom_lz3_test.lua" } + +local function suites() + local files = {} + for _, name in ipairs(FsIo.listDir("tests")) do + if name:match("%.lua$") then + for _, prefix in ipairs(PREFIXES) do + if name:sub(1, #prefix) == prefix then + files[#files + 1] = "tests/" .. name + break + end + end + end + end + for _, path in ipairs(EXTRA) do + local handle = io.open(path, "r") + if handle then + handle:close() + files[#files + 1] = path + end + end + table.sort(files) + return files +end + +local lua = (arg and arg[-1]) or "luajit" +local failed, total = 0, 0 +for _, path in ipairs(suites()) do + total = total + 1 + local status = os.execute(("%s %s"):format(lua, path)) + if status == 0 or status == true then + print("ok " .. path) + else + failed = failed + 1 + print("FAIL " .. path) + end +end + +print(("\ngen2: %d/%d suites passed"):format(total - failed, total)) +print(("%s"):format(failed == 0 and "ALL TESTS PASSED" or failed .. " FAILURES")) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b2c10116..2b271766 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1338,7 +1338,7 @@ do Game.save.pokedex.owned.EKANS = nil local cb = BattleState.newWild(Game, "EKANS", 5) cb:storeCaughtMon() - check(hasText(cb, "New POKéDEX data\nwill be added for\nEKANS!"), + check(hasText(cb, "New POKéDEX data\nwill be added for\vEKANS!"), "_ItemUseBallText06 on a first catch") check(Game.save.pokedex.owned.EKANS == true, "species registered as owned") eq(cb.result, "caught", "catch resolves the battle") @@ -1348,7 +1348,7 @@ do for _ = 1, 6 do table.insert(Game.save.party, Pokemon.new(Data, "RATTATA", 5)) end local cb2 = BattleState.newWild(Game, "EKANS", 5) cb2:storeCaughtMon() - check(hasText(cb2, "EKANS was\ntransferred to\nsomeone's PC!"), + check(hasText(cb2, "EKANS was\ntransferred to\vsomeone's PC!"), "_ItemUseBallText08 before meeting Bill") check(not hasText(cb2, "New POKéDEX data"), "no dex page for an already-owned species") @@ -1372,7 +1372,7 @@ do Game.save.flags.EVENT_MET_BILL = true local cb3 = BattleState.newWild(Game, "EKANS", 5) cb3:storeCaughtMon() - check(hasText(cb3, "EKANS was\ntransferred to\nBILL's PC!"), + check(hasText(cb3, "EKANS was\ntransferred to\vBILL's PC!"), "_ItemUseBallText07 after meeting Bill") Game.save.flags.EVENT_MET_BILL = nil @@ -3485,10 +3485,14 @@ runSuites({ "tests/rom_importer_cursor_test.lua" }) -- ---------------------------------------------- launcher last played tab (#835) runSuites({ "tests/rom_importer_last_version_test.lua" }) --- ---------------------------------------------- Gold (Gen 2) --- All ROM-free: each file carries its own fixtures shaped like the extractor's --- output, so they run without a Gold cache. -runSuites({ +-- ---------------------------------------------- Gold / Crystal (Gen 2) +-- All ROM-free: own fixtures, or a self-skip on a missing cache. Globbed on +-- the historical chain; tests/run_gen2.lua runs the same set, one per process. +local LEAKS_SAVE_SLOT_STATE = { + ["tests/gen2_save_export_test.lua"] = true, +} +runSuites(orderedGlob( + "tests/gen2_*.lua tests/crystal_*.lua tests/rom_lz3_test.lua", { "tests/rom_lz3_test.lua", "tests/gen2_world_test.lua", "tests/gen2_audio_test.lua", @@ -3599,10 +3603,8 @@ runSuites({ -- name resolution, and the .sav converter refusing a Gen 2 save table. "tests/gen2_sound_alias_test.lua", "tests/gen2_save_convert_cli_test.lua", - -- The wall radios (`special MapRadio`); the Pokegear-proper suites next to - -- it (gen2_pokegear_unlock_test, gen2_save_export_test) stay out of this - -- block because their headers ask for a GOLD_CACHE, which this tier cannot - -- assume. + -- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a + -- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone. "tests/gen2_map_radio_test.lua", -- The two seams where a battle meets everything else: what ends a round and -- a battle (and what a battle may not leave on the party), and BattlePack -- @@ -3613,7 +3615,13 @@ runSuites({ -- which failures suppress the attack animation, and the Rollout / -- EFFECT_RAMPAGE lock-ins. ROM-free like the rest of this block. "tests/gen2_battle_lockin_test.lua", -}) + -- Crystal rides the same glob: crystal_*.lua and the gen2_crystal_* files. + "tests/crystal_import_test.lua", + "tests/crystal_world_test.lua", + "tests/gen2_crystal_anim_test.lua", + "tests/gen2_crystal_caught_data_test.lua", + "tests/gen2_crystal_gender_test.lua", +}, LEAKS_SAVE_SLOT_STATE)) -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index b1877437..5383a8fd 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -1,4 +1,4 @@ --- Headless Gold save-editor rules. Run from repo root: +-- Headless Gen 2 save-editor rules. Run from repo root: -- luajit tests/save_editor_gen2_tests.lua package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" .. ";./tools/save-editor/panels/?.lua" @@ -90,6 +90,7 @@ do eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field") eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold") eq(Gen.of({ version = "silver" }), 2, "Gen.of version silver") + eq(Gen.of({ version = "crystal" }), 2, "Gen.of version crystal") eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame") eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame") end @@ -116,7 +117,7 @@ do check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable") end -for _, version in ipairs({ "gold", "silver" }) do +for _, version in ipairs({ "gold", "silver", "crystal" }) do local S = newState(version) Ops.partyAdd(S) eq(#S.save.party, 1, "partyAdd on " .. version) @@ -424,5 +425,398 @@ do GameVersion.set("red") end +do + GameVersion.set("red") + local crystal = Gen.newGame("crystal") + eq(crystal.version, "crystal", "Gen.newGame('crystal') stamps crystal") + eq(crystal.generation, 2, "crystal stub is generation 2") + eq(crystal.player.name, "CHRIS", "crystal stub uses Crystal's preset name") + eq(Gen.newGame("silver").version, "silver", "Gen.newGame('silver') stamps silver") + eq(Gen.newGame("silver").player.name, "SILVER", "silver stub keeps SILVER") + eq(Gen.newGame("gold").player.name, "GOLD", "gold stub keeps GOLD") + eq(Gen.newGame("blue").version, "blue", "Gen.newGame('blue') stamps blue") + eq(Gen.newGame("yellow").version, "yellow", "Gen.newGame('yellow') stamps yellow") + eq(Gen.newGame(nil).version, "red", "no version falls back to the active game") + eq(Gen.newGame("nonsense").version, "red", "an unknown id is ignored") +end + +do + local crystal = Gen.newGame("crystal") + local gold = Gen.newGame("gold") + eq(Gen.versionOf(crystal), "crystal", "versionOf reads the save") + eq(Gen.engineOf(crystal), "crystal", "crystal saves are the crystal lineage") + eq(Gen.engineOf(gold), "gs", "gold saves are the gs lineage") + eq(Gen.engineOf(Gen.newGame("silver")), "gs", "silver saves are the gs lineage") + eq(Gen.editionLabel(crystal), "CRYSTAL", "edition label follows the save") + eq(Gen.editionLabel(gold), "GOLD", "gold still labels GOLD") + check(Gen.hasCaughtData(crystal), "crystal has caught data") + check(not Gen.hasCaughtData(gold), "gold has no caught data") + check(not Gen.hasCaughtData(SaveData.newGame()), "gen 1 has no caught data") + check(Gen.hasPlayerGender(crystal), "crystal has a player gender") + check(not Gen.hasPlayerGender(gold), "gold has no player gender") +end + +do + local Gen2Flags = require("Gen2Flags") + local gs = Gen2Flags.byName("gs") + local crys = Gen2Flags.byName("crystal") + eq(gs.EVENT_BEAT_FALKNER, crys.EVENT_BEAT_FALKNER, + "a shared event keeps one id in both lineages") + check(gs.EVENT_BEAT_FALKNER ~= nil, "the gs table is populated") + eq(gs.EVENT_GOT_RAINBOW_WING, 120, "Gold's EVENT_GOT_RAINBOW_WING") + eq(crys.EVENT_GOT_RAINBOW_WING, 822, "Crystal renumbers EVENT_GOT_RAINBOW_WING") + eq(gs.EVENT_TIN_TOWER_1F_SUICUNE, nil, "a Crystal-only event is absent on gs") + eq(crys.EVENT_TIN_TOWER_1F_SUICUNE, 1970, "and resolves on crystal") + eq(crys.EVENT_WADE_READY_FOR_REMATCH, nil, "a retired Gold event is absent on crystal") + check(gs.EVENT_WADE_READY_FOR_REMATCH ~= nil, "and still resolves on gs") + check(Gen2Flags.byName("crystal") == crys, "byName caches per lineage") + check(Gen2Flags.byName("gold") == gs, "an unknown lineage reads as gs") +end + +do + local crystalNames = Catalog.gen2EventList("crystal") + local goldNames = Catalog.goldEventList() + local function has(list, name) + for _, n in ipairs(list) do if n == name then return true end end + return false + end + check(has(crystalNames, "EVENT_TIN_TOWER_1F_SUICUNE"), + "the crystal event list names Crystal's own flags") + check(not has(crystalNames, "EVENT_WADE_READY_FOR_REMATCH"), + "the crystal event list drops Gold's retired flags") + check(has(goldNames, "EVENT_WADE_READY_FOR_REMATCH"), + "the gold event list keeps them") + check(has(goldNames, "EVENT_BEAT_FALKNER"), "gold event list still has Falkner") + check(#crystalNames > #goldNames, "Crystal has more event flags than Gold") +end + +do + local S = newState("crystal") + local name = "EVENT_TIN_TOWER_1F_SUICUNE" + Ops.setFlag(S, name, true) + check(Gen.getFlag(S.save, name), "a Crystal-only event reads back set") + eq(S.save.flags[name], nil, "and never lands as a string key") + check(next(S.save.events) ~= nil, "it wrote the numeric bitfield") + + local G = newState("gold") + Ops.setFlag(G, name, true) + eq(G.save.flags[name], true, "the same name on Gold has no id and stays named") + + local moved = newState("crystal") + Ops.setFlag(moved, "EVENT_GOT_RAINBOW_WING", true) + local Events2 = require("src.world.gen2.Events") + local ev = Events2.new() + ev:restore(moved.save.events) + check(ev:get(822), "EVENT_GOT_RAINBOW_WING sets Crystal's bit 822") + check(not ev:get(120), "not Gold's bit 120") +end + +do + local S = newState("crystal") + Ops.partyAdd(S) + local mon = S.save.party[1] + + check(Ops.setCaughtTime(S, mon, 2), "caught time set") + eq(mon.caughtTime, 2, "caught time DAY") + check(S.dirty, "caught time dirties the save") + check(not Ops.setCaughtTime(S, mon, 2), "re-setting the same time only says") + Ops.setCaughtTime(S, mon, 9) + eq(mon.caughtTime, 3, "caught time clamps to NITE") + Ops.setCaughtTime(S, mon, -1) + eq(mon.caughtTime, 0, "caught time clamps to unknown") + + Ops.setCaughtLevel(S, mon, 40) + eq(mon.caughtLevel, 40, "caught level 40") + Ops.setCaughtLevel(S, mon, 99) + eq(mon.caughtLevel, 63, "caught level clamps to CAUGHT_LEVEL_MASK") + + Ops.setCaughtLocation(S, mon, 12) + eq(mon.caughtLocation, 12, "caught location 12") + Ops.setCaughtLocation(S, mon, -1) + eq(mon.caughtLocation, 127, "stepping below 0 wraps to LANDMARK_EVENT") + Ops.setCaughtLocation(S, mon, 128) + eq(mon.caughtLocation, 0, "and stepping past the mask wraps back to unknown") + + Ops.setCaughtByGender(S, mon, "girl") + eq(mon.caughtByGender, "girl", "caught by girl") + Ops.setCaughtByGender(S, mon, "none") + eq(mon.caughtByGender, nil, "caught by clears to nil, the Gold-save value") + + local Mon = require("src.battle.gen2.Mon") + Ops.setCaughtTime(S, mon, 3) + Ops.setCaughtLevel(S, mon, 20) + Ops.setCaughtLocation(S, mon, 5) + Ops.setCaughtByGender(S, mon, "girl") + local byte0, byte1 = Mon.packCaughtData(mon) + eq(byte0, 3 * 0x40 + 20, "the editor's fields pack into byte 0") + eq(byte1, 0x80 + 5, "and into byte 1") +end + +do + local G = newState("gold") + Ops.partyAdd(G) + local mon = G.save.party[1] + G.dirty = false + check(not Ops.setCaughtTime(G, mon, 2), "Gold refuses caught time") + eq(mon.caughtTime, nil, "and writes nothing") + check(not Ops.setCaughtLevel(G, mon, 20), "Gold refuses caught level") + check(not Ops.setCaughtLocation(G, mon, 5), "Gold refuses caught location") + check(not Ops.setCaughtByGender(G, mon, "boy"), "Gold refuses caught gender") + check(not G.dirty, "a refused caught edit never dirties") +end + +do + local S = newState("crystal") + eq(Gen.playerGender(S.save), "male", "a new Crystal save starts male") + check(Ops.setPlayerGender(S, "female"), "player gender set") + eq(S.save.player.gender, "female", "written to save.player.gender") + check(not Ops.setPlayerGender(S, "female"), "re-setting the same gender only says") + Ops.setPlayerGender(S, "male") + eq(Gen.playerGender(S.save), "male", "and back") + + local G = newState("gold") + check(not Ops.setPlayerGender(G, "female"), "Gold refuses a gender change") + eq(G.save.player.gender, "male", "Gold's field is left alone") +end + +do + local landmarks = { + gen2Landmarks = { + landmarks = { + LANDMARK_AZALEA_TOWN = { id = "LANDMARK_AZALEA_TOWN", index = 12, + name = "AZALEA TOWN" }, + LANDMARK_BURNED_TOWER = { id = "LANDMARK_BURNED_TOWER", index = 24, + name = "BURNED\nTOWER" }, + }, + }, + } + eq(Gen.landmarkName(landmarks, 12), "AZALEA TOWN", "landmark by index") + eq(Gen.landmarkName(landmarks, 24), "BURNED TOWER", "two-line names flatten") + eq(Gen.landmarkName(landmarks, 0), "UNKNOWN", "0 reads as unknown") + eq(Gen.landmarkName(landmarks, 0x7e), "GIFT", "LANDMARK_GIFT") + eq(Gen.landmarkName(landmarks, 0x7f), "EVENT", "LANDMARK_EVENT") + eq(Gen.landmarkName(landmarks, 99), "#99", "an unmapped index shows its number") +end + +do + local Kit = require("Kit") + local MonEditor = require("MonEditor") + local ItemsPanel = require("Items") + + local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 + end + + local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 + end + + local function auditFrame(label, W, H) + local controls = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then controls[#controls + 1] = r end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions, escapes = 0, 0 + for i = 1, #controls do + local a = controls[i] + local x1, y1, x2, y2 = clipped(a) + if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then + escapes = escapes + 1 + print((" escape: %s (%.0f,%.0f %.0fx%.0f)") + :format(a.label, a.x, a.y, a.w, a.h)) + end + for j = i + 1, #controls do + if overlap(a, controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(a.label, controls[j].label, a.x, a.y, + controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + check(escapes == 0, label .. ": every control stays inside the panel") + end + + local sizes = { { 420, 700 }, { 640, 768 }, { 1280, 720 } } + for _, version in ipairs({ "gold", "crystal" }) do + for _, size in ipairs(sizes) do + local W, H = size[1], size[2] + local S = newState(version) + Ops.partyAdd(S) + Ops.selectParty(S, 1) + Ops.addToBag(S, S.cat.items[1]) + Ops.addToPc(S, S.cat.items[2]) + Kit.layout(W, H) + for _, panel in ipairs({ { "inspector", MonEditor }, { "items", ItemsPanel } }) do + local label = ("%s %dx%d %s"):format(version, W, H, panel[1]) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + local ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H) + check(ok, label .. " draws: " .. tostring(err)) + if ok then + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H) + check(ok, label .. " redraws: " .. tostring(err)) + end + if ok then auditFrame(label, W, H) end + Kit.audit = nil + end + end + end + + local S = newState("crystal") + Ops.partyAdd(S) + Ops.selectParty(S, 1) + Kit.layout(1280, 720) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(S, Kit, 0, 0, 1280, 720) + local labels = {} + for _, r in ipairs(Kit.audit) do labels[r.label] = true end + Kit.audit = nil + check(labels.MORN and labels.NITE, "the Crystal inspector offers the caught times") + check(labels.BOY and labels.GIRL, "and the OT gender chips") + + local G = newState("gold") + Ops.partyAdd(G) + Ops.selectParty(G, 1) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(G, Kit, 0, 0, 1280, 720) + local goldLabels = {} + for _, r in ipairs(Kit.audit) do goldLabels[r.label] = true end + Kit.audit = nil + check(not goldLabels.MORN, "the Gold inspector has no caught row") + + local function itemsLabels(state) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + ItemsPanel.draw(state, Kit, 0, 0, 1280, 720) + local seen = {} + for _, r in ipairs(Kit.audit) do seen[r.label] = true end + Kit.audit = nil + return seen + end + check(itemsLabels(S).GIRL, "the Crystal Items tab carries the TRAINER card") + check(not itemsLabels(G).GIRL, "the Gold Items tab does not") + + local function bottomOverflow(state) + local H = 380 + Kit.layout(640, H) + state.inspectorScroll = 100000 + for _ = 1, 2 do + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(state, Kit, 0, 0, 640, H) + end + local lowest = 0 + for _, r in ipairs(Kit.audit) do + if r.class == "control" then lowest = math.max(lowest, r.y + r.h) end + end + Kit.audit = nil + return math.floor(lowest - H + 0.5) + end + local goldOver = bottomOverflow(G) + eq(bottomOverflow(S), goldOver, + "the caught rows are fully counted in the inspector's scroll height") + check(goldOver <= 4, ("the inspector reaches its last control (%d px short)") + :format(goldOver)) +end + +local function readable(path) + local fh = io.open(path, "r") + if not fh then return false end + fh:close() + return true +end + +do + local Gen2Flags = require("Gen2Flags") + local goldAsm = (os.getenv("POKEGOLD") or "../pokegold") + .. "/constants/event_flags.asm" + local crystalAsm = (os.getenv("POKECRYSTAL") or "../pokecrystal") + .. "/constants/event_flags.asm" + if readable(goldAsm) and readable(crystalAsm) and readable("tools/goldwalk/flags.lua") then + local Flags = dofile("tools/goldwalk/flags.lua") + local gold = Flags.parse(goldAsm) + local crys = Flags.parse(crystalAsm) + local addedBad, removedBad, missing = 0, 0, 0 + for name, id in pairs(crys) do + if gold[name] ~= id and Gen2Flags.CRYSTAL_ADDED[name] ~= id then + missing = missing + 1 + end + end + for name, id in pairs(Gen2Flags.CRYSTAL_ADDED) do + if crys[name] ~= id then addedBad = addedBad + 1 end + end + for name in pairs(Gen2Flags.CRYSTAL_REMOVED) do + if crys[name] ~= nil or gold[name] == nil then removedBad = removedBad + 1 end + end + eq(missing, 0, "every Crystal event Gold disagrees on is in CRYSTAL_ADDED") + eq(addedBad, 0, "every CRYSTAL_ADDED id matches pokecrystal") + eq(removedBad, 0, "every CRYSTAL_REMOVED name is Gold-only") + local byName = Gen2Flags.byName("crystal") + local wrong = 0 + for name, id in pairs(crys) do + if byName[name] ~= id then wrong = wrong + 1 end + end + eq(wrong, 0, "the resolved crystal table is pokecrystal's whole event list") + else + check(true, "pokegold / pokecrystal absent : flag delta cross-check SKIPPED") + end +end + +do + local Gen2Flags = require("Gen2Flags") + local cache = os.getenv("CRYSTAL_CACHE") + or ((os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/lead-crystal/crystal") + local initialPath = cache .. "/data/generated/initial_events.lua" + local stdScripts = (os.getenv("POKECRYSTAL") or "../pokecrystal") + .. "/engine/events/std_scripts.asm" + if readable(initialPath) and readable(stdScripts) then + local Flags = dofile("tools/goldwalk/flags.lua") + local romIds = assert(loadfile(initialPath))().flags + -- InitializeEventsScript sets EVENT_AZALEA_TOWN_KURT twice in a row + -- (../pokecrystal/engine/events/std_scripts.asm:575-576). + local names = {} + for _, n in ipairs(Flags.setEventsOf(stdScripts, "InitializeEventsScript")) do + if names[#names] ~= n then names[#names + 1] = n end + end + local byName = Gen2Flags.byName("crystal") + eq(#names, #romIds, "InitializeEventsScript sets as many flags as the cart") + local mismatches, lo, hi = 0, math.huge, -math.huge + for i, name in ipairs(names) do + local got = romIds[i] + if got then lo, hi = math.min(lo, got), math.max(hi, got) end + if byName[name] ~= got then + mismatches = mismatches + 1 + if mismatches <= 5 then + print((" #%d %s table=%s cart=%s") + :format(i, name, tostring(byName[name]), tostring(got))) + end + end + end + eq(mismatches, 0, + ("crystal names == cart ids across %d..%d"):format(lo, hi)) + else + check(true, "crystal cache absent : cart cross-check SKIPPED") + end +end + print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index e76c17c2..31e06cca 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -34,6 +34,28 @@ DATASETS = ( "text", "field", "battle_anims", ) +# Sound is decoded by the Lua importer (src/import/RomExtractor.lua), not here, +# so --clean must step around it rather than delete what it cannot rebuild. +UNOWNED_DATA = ("audio.lua",) +UNOWNED_ASSETS = ("audio",) + + +def clean_generated(out_dir, assets_dir): + """Empty the generated dirs, keeping artifacts this tool never writes.""" + kept = [] + for path, spared in ((out_dir, UNOWNED_DATA), (assets_dir, UNOWNED_ASSETS)): + if not os.path.isdir(path): + continue + for name in os.listdir(path): + target = os.path.join(path, name) + if name in spared: + kept.append(target) + elif os.path.isdir(target): + shutil.rmtree(target) + else: + os.remove(target) + return kept + _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) VERSION_MANIFESTS = { "red": os.path.join(_TOOLS_DIR, "rom_manifest.json"), @@ -2207,9 +2229,9 @@ def main(argv=None): out_dir = args.out or prefix + os.path.join("data", "generated") assets_dir = args.assets or prefix + os.path.join("assets", "generated") if args.clean: - for path in (out_dir, assets_dir): - if os.path.isdir(path): - shutil.rmtree(path) + kept = clean_generated(out_dir, assets_dir) + for path in kept: + print(f"kept {path} (not produced by this tool)") os.makedirs(out_dir, exist_ok=True) os.makedirs(assets_dir, exist_ok=True) datasets = tuple(args.only) if args.only else DATASETS diff --git a/tools/crystal_movie_symbols.py b/tools/crystal_movie_symbols.py new file mode 100644 index 00000000..82d8a12e --- /dev/null +++ b/tools/crystal_movie_symbols.py @@ -0,0 +1,72 @@ +"""Crystal intro-movie and title-screen symbols for the Crystal manifest. + +Gold's title/intro symbols do not exist in Crystal: CrystalIntro is a different +program with its own asset set (engine/movie/intro.asm:1678-1777) and the title +composes on the fly with no tilemap (engine/movie/title.asm:364-374). +""" + +# engine/movie/intro.asm:1678-1777 +INTRO_SYMBOLS = [ + "IntroSuicuneRunGFX", + "IntroPichuWooperGFX", + "IntroBackgroundGFX", + "IntroBackgroundTilemap", + "IntroBackgroundAttrmap", + "IntroBackgroundPalette", + "IntroUnownsGFX", + "IntroPulseGFX", + "IntroUnownATilemap", + "IntroUnownAAttrmap", + "IntroUnownHITilemap", + "IntroUnownHIAttrmap", + "IntroUnownsTilemap", + "IntroUnownsAttrmap", + "IntroUnownsPalette", + "IntroCrystalUnownsGFX", + "IntroCrystalUnownsTilemap", + "IntroCrystalUnownsAttrmap", + "IntroCrystalUnownsPalette", + "IntroSuicuneCloseGFX", + "IntroSuicuneCloseTilemap", + "IntroSuicuneCloseAttrmap", + "IntroSuicuneClosePalette", + "IntroSuicuneJumpGFX", + "IntroSuicuneBackGFX", + "IntroSuicuneJumpTilemap", + "IntroSuicuneJumpAttrmap", + "IntroSuicuneBackTilemap", + "IntroSuicuneBackAttrmap", + "IntroSuicunePalette", + "IntroUnownBackGFX", + "IntroGrass1GFX", + "IntroGrass2GFX", + "IntroGrass3GFX", + "IntroGrass4GFX", +] + +# Palette fades that live as local labels inside their scene routines. +# engine/movie/intro.asm:1189 (fade.pal), :1385-1388 (unown_1.pal / unown_2.pal) +INTRO_FADE_SYMBOLS = [ + "Intro_Scene24_ApplyPaletteFade.FadePals", + "Intro_Scene20_AppearUnown.pal1", + "Intro_Scene20_AppearUnown.pal2", + "Intro_FadeUnownWordPals.FastFadePalettes", + "Intro_FadeUnownWordPals.SlowFadePalettes", +] + +# engine/movie/title.asm:364-374 +TITLE_SYMBOLS = [ + "TitleSuicuneGFX", + "TitleLogoGFX", + "TitleCrystalGFX", + "TitleScreenPalettes", +] + +# engine/movie/splash.asm:344 -- replaces Gold's GameFreakLogoStarsGFX +SPLASH_SYMBOLS = [ + "GameFreakDittoGFX", + "GameFreakDittoPaletteFade", +] + +MOVIE_SYMBOLS = ( + INTRO_SYMBOLS + INTRO_FADE_SYMBOLS + TITLE_SYMBOLS + SPLASH_SYMBOLS) diff --git a/tools/crystal_symbol_deltas.py b/tools/crystal_symbol_deltas.py new file mode 100644 index 00000000..800da88d --- /dev/null +++ b/tools/crystal_symbol_deltas.py @@ -0,0 +1,147 @@ +"""REQUIRED_SYMBOLS delta between pokegold.sym and pokecrystal.sym. + +make_gold_manifest.REQUIRED_SYMBOLS names 340 symbols by hand. 25 of them do +not exist in pokecrystal.sym: Crystal renamed the credits mons, split the +trainer-card / Pokegear / pack-pals blocks by player gender, split the two +FontsExtra tiles apart, and replaced the whole Gold/Silver intro movie and +title screen. DROP_SYMBOLS lists those 25 and ADD_SYMBOLS the verified +replacements; the intro, title and splash names come from +crystal_movie_symbols.MOVIE_SYMBOLS (that file is the pinned CT-9 list and is +not edited here). MOBILE_SYMBOLS is the Mobile System GB art, which no Gold +or Silver ROM carries at all. + +Every name below was checked against ../pokecrystal-symbols/pokecrystal.sym. +""" + +from __future__ import annotations + +from crystal_movie_symbols import MOVIE_SYMBOLS + +# Gold-only names, grouped by what replaces them. +DROP_SYMBOLS = frozenset({ + # engine/movie/title.asm:364-373 -- no tilemap; DrawTitleGraphic composes. + "TitleScreenGFX1", "TitleScreenGFX2", "TitleScreenGFX3", + "TitleScreenGFX4", "TitleScreenTilemap", + # engine/movie/credits.asm:610-613 + "CreditsBellossomGFX", "CreditsTogepiGFX", + "CreditsElekidGFX", "CreditsSentretGFX", + # gfx/misc.asm:44 + "GameFreakLogoStarsGFX", + # engine/gfx/player_gfx.asm:114,117,120,203,206 + "ChrisPicAndTrainerCardGFX", + # engine/gfx/color.asm:1330,1333 + "PokegearPals", + # engine/gfx/cgb_layouts.asm:818,821 + "_CGB_PackPals.PackPals", + # gfx/font.asm:51,63 + "FontsExtra_SolidBlackAndUpArrowGFX", + # engine/movie/intro.asm:1 -- CrystalIntro is a different program. + "Intro_WaterGFX1", "Intro_WaterTilemap", "Intro_WaterMeta", + "Intro_WaterGFX2", + "Intro_GrassGFX1", "Intro_GrassTilemap", "Intro_GrassMeta", + "Intro_GrassGFX2", + "Intro_FireGFX1", "Intro_FireGFX2", "Intro_FireGFX3", +}) + +# Crystal replacements, minus the intro/title/splash ones MOVIE_SYMBOLS owns. +ADD_SYMBOLS = frozenset({ + # engine/movie/credits.asm:610-613 + "CreditsPichuGFX", "CreditsSmoochumGFX", + "CreditsDittoGFX", "CreditsIgglybuffGFX", + # engine/gfx/player_gfx.asm:114,117,120,203,206 + "ChrisCardPic", "KrisCardPic", "TrainerCardGFX", "ChrisPic", "KrisPic", + # engine/gfx/color.asm:1330,1333 + "MalePokegearPals", "FemalePokegearPals", + # engine/gfx/cgb_layouts.asm:818,821 + "_CGB_PackPals.ChrisPackPals", "_CGB_PackPals.KrisPackPals", + # gfx/font.asm:51,63 -- black is 1bpp at tile $60, up_arrow 2bpp at $61. + "FontsExtra_SolidBlackGFX", "FontsExtra2_UpArrowGFX", + # main.asm:425-448 -- the pic-animation pointer tables, absent from Gold. + "AnimationPointers", "AnimationIdlePointers", + "BitmasksPointers", "FramesPointers", + "UnownAnimationPointers", "UnownAnimationIdlePointers", + "UnownBitmasksPointers", "UnownFramesPointers", + # engine/tilesets/tileset_anims.asm -- the five Crystal-only anim steps. + "AnimateFountainTile", + "ForestTreeLeftAnimation", "ForestTreeRightAnimation", + "ForestTreeLeftAnimation2", "ForestTreeRightAnimation2", + # engine/overworld/wildmons.asm:493-524 -- Raikou and Entei only. + "InitRoamMons", + # data/events/unown_walls.asm:7,15 -- the four Ruins of Alph wall words + # and the menu box each one is drawn in. + "UnownWalls", "MenuHeaders_UnownWalls", + # data/battle_tower/classes.asm:6 and data/battle_tower/parties.asm:1 -- + # the Battle Tower roster, which no Gold or Silver ROM carries; the + # sprites table is engine/events/battle_tower/battle_tower.asm:1578 + # INCLUDE "data/trainers/sprites.asm". + "BattleTowerTrainers", "BattleTowerMons", "BTTrainerClassSprites", + # data/battle_tower/unknown.asm:1, copied into wBT_OTTrainerData whole. + "BattleTowerTrainerData", + # engine/events/battle_tower/load_trainer.asm:24,112 -- the two rejection + # loops whose `maskbits` / `cp` pair carries the sample ceiling. Crystal + # 1.0 and 1.1 differ only in the trainer one (:29-37), so it is read out + # of the cart rather than written down. + "LoadOpponentTrainerAndPokemon.resample", + "LoadRandomBattleTowerMon.resample", +}) + +# Mobile System GB art, all of it present in the international v1.0 object: +# mobile/*.asm assembles there, only the menus that reach it are Japan-only. +MOBILE_SYMBOLS = frozenset({ + # mobile/mobile_5c.asm:290,293,296,753,756-771,866,874,878 + "AsciiFontGFX", "PichuAnimatedMobileGFX", "ElectroBallMobileGFX", + "PichuBorderMobileGFX", "Stadium2N64GFX", "Stadium2N64Tilemap", + "Stadium2N64Attrmap", "PasswordTopTilemap", "PasswordBottomTilemap", + "PasswordShiftTilemap", "ChooseMobileCenterTilemap", + "MobilePasswordAttrmap", "ChooseMobileCenterAttrmap", + "MobilePasswordPalettes", + # mobile/mobile_5e.asm:2,5,8,11,14,18,925,928,931,934,941 + "MobileCardGFX", "ChrisSilhouetteGFX", "KrisSilhouetteGFX", + "MobileCard2GFX", "CardLargeSpriteAndFolderGFX", "CardSpriteGFX", + "DialpadTilemap", "DialpadAttrmap", "DialpadGFX", "DialpadCursorGFX", + "MobileCardListGFX", + # mobile/mobile_5f.asm:84,87,91,3528,3534,3537 + "HaveWantGFX", "MobileSelectGFX", "HaveWantMap", "PokemonNewsGFX", + "PokemonNewsTileAttrmap", "PokemonNewsPalettes", + # mobile/mobile_5b.asm:206,209,212,215,758 + "MobileSystemSplashScreen_InitGFX.Tiles", + "MobileSystemSplashScreen_InitGFX.Tilemap", + "MobileSystemSplashScreen_InitGFX.Attrmap", + "MobileSplashScreenPalettes", "MobileAdapterCheckGFX", + # mobile/mobile_42.asm:1733-1763 and mobile/mobile_40.asm:6921,6924 + "MobileTradeSpritesGFX", "MobileTradeGFX", "MobileTradeTilemapLZ", + "MobileTradeAttrmapLZ", "MobileCable1GFX", "MobileCable2GFX", + "UnusedMobilePulsePalettes", "MobileTradeBGPalettes", + "MobileTradeOB1Palettes", "MobileTradeOB2Palettes", + "MobileAdapterPalettes", "MobileTradeLightsGFX", + "MobileTradeLightsPalettes", + # mobile/mobile_45_2.asm:1362,1365,1368 and + # mobile/mobile_45_sprite_engine.asm:311 + "PichuBorderMobileOBPalettes", "PichuBorderMobileBGPalettes", + "PichuBorderMobileTilemapAttrmap", "MobileDialingGFX", + # mobile/mobile_12.asm:1007,1010, mobile/mobile_22.asm:518, + # mobile/mobile_41.asm:1115, mobile/fixed_words.asm:3231 + "MobileUpArrowGFX", "MobileDownArrowGFX", "EZChatCursorGFX", + "MobileDialingFrameGFX", "SelectStartGFX", + # engine/menus/main_menu.asm:24 and gfx/font.asm:58 + "MobileMenuGFX", "MobilePhoneTilesGFX", + # engine/link/mystery_gift.asm:1606,1920,1923 + "MysteryGiftGFX", "CardTradeGFX", "CardTradeSpriteGFX", +}) + + +def crystal_required(gold_required): + """Apply the delta to make_gold_manifest.REQUIRED_SYMBOLS.""" + gold = frozenset(gold_required) + stale = sorted(DROP_SYMBOLS - gold) + if stale: + raise SystemExit( + "crystal_symbol_deltas: DROP_SYMBOLS names that Gold no longer " + "requires: " + ", ".join(stale)) + extra = ADD_SYMBOLS | MOBILE_SYMBOLS | set(MOVIE_SYMBOLS) + collisions = sorted(extra & gold) + if collisions: + raise SystemExit( + "crystal_symbol_deltas: ADD names already in the Gold set: " + + ", ".join(collisions)) + return frozenset((gold - DROP_SYMBOLS) | extra) diff --git a/tools/make_crystal_manifest.py b/tools/make_crystal_manifest.py new file mode 100644 index 00000000..033686a6 --- /dev/null +++ b/tools/make_crystal_manifest.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Generate tools/rom_manifest_crystal.json from pret/pokecrystal. + +Crystal is its own pret tree, but the manifest SHAPE is Gold's: same constant +blocks, same charmap parser, same symbol resolution. So this drives +make_gold_manifest.generate() over ../pokecrystal instead of forking it, the +way make_yellow_manifest.py drives make_rom_manifest's helpers over +pokeyellow. Three things are passed in: + + * defines -- the EMPTY set. ../pokecrystal/Makefile:129 builds the retail + international v1.0 object with no -D at all (:130-134 add + _CRYSTAL11 / _CRYSTAL_AU / _DEBUG / _CRYSTAL11_VC for the four + other targets). Leaving Gold's {"_GOLD"} in place would take + an `IF DEF(_GOLD)` arm that Crystal never assembles. + * required -- crystal_symbol_deltas.crystal_required(), the Gold symbol set + with the 25 Gold-only names swapped for their Crystal + replacements plus crystal_movie_symbols.MOVIE_SYMBOLS. + * sha1 -- the Crystal cart hash. + +`anim_labels` is on: Crystal has per-species pic-animation tables +(../pokecrystal/main.asm:425-448) and Gold has none. + +Crystal also declares two extra RGBDS charmaps that Gold has not got at all +(../pokecrystal/constants/charmap.asm:422-442): `unown`, used by the Ruins of +Alph wall words, and `ascii`, used only by the Mobile System GB code. Neither +is more rows of the main charmap -- the same byte means a different thing in +each -- so make_gold_manifest.charmap deliberately STOPS at the first +`newcharmap`, and that guard has to keep protecting the main table. The Unown +one is carried here instead, as its own top-level `unownCharmap` key: that is +where `charmap` and `fontCharmap` already live, so a consumer picks the map it +wants by name and no reader of the main table can see these bytes. The `ascii` +map is not emitted, because nothing outside the Mobile System reads it. + +Usage: python3 tools/make_crystal_manifest.py +Default paths: pokecrystal at ../pokecrystal (relative to the repo) or +/Users/bryanbassett/Documents/development/pokecrystal; symbols at +/Users/bryanbassett/Documents/development/pokecrystal-symbols/pokecrystal.sym. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import make_gold_manifest as gold # noqa: E402 +from crystal_symbol_deltas import crystal_required # noqa: E402 +from rom_data import CANONICAL_CRYSTAL_SHA1 # noqa: E402 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEV = "/Users/bryanbassett/Documents/development" +DEFAULT_POKECRYSTAL_CANDIDATES = [ + os.path.join(os.path.dirname(REPO_ROOT), "pokecrystal"), + os.path.join(DEV, "pokecrystal"), +] +DEFAULT_SYMBOLS = os.path.join(DEV, "pokecrystal-symbols/pokecrystal.sym") +DEFAULT_OUT = os.path.join( + os.path.dirname(__file__), "rom_manifest_crystal.json") + +# ../pokecrystal/Makefile:129 +CRYSTAL_ASM_DEFINES = frozenset() + +CRYSTAL_REQUIRED_SYMBOLS = crystal_required(gold.REQUIRED_SYMBOLS) + + +def _rgbds_int(expr, i): + """Evaluate one `charmap` value expression for loop counter `i`. + + RGBDS `$xx` is hex and `/` is integer division; nothing else in the block + needs modelling. The whitelist is what keeps this from being eval() on + arbitrary asm. + """ + text = re.sub(r"\$([0-9a-fA-F]+)", lambda m: str(int(m.group(1), 16)), expr) + text = text.replace("/", "//") + if not re.fullmatch(r"[0-9i()+\-*/ ]+", text): + raise ValueError(f"unsupported charmap expression: {expr}") + return int(eval(text, {"__builtins__": {}}, {"i": i})) # noqa: S307 + + +def unown_charmap(pokecrystal, defines=None): + """The `unown` charmap: byte -> letter, same shape as gold.charmap. + + ../pokecrystal/constants/charmap.asm:422-431 is a `pushc` block that names + the map, DEFs a string of every printable character, and emits one + `charmap STRSLICE(...)` per character inside a `for`. So the parse has to + follow the loop rather than read literal rows -- but it still reads the + letter set and the tile arithmetic out of the asm, not out of a copy here. + """ + path = os.path.join(pokecrystal, "constants", "charmap.asm") + out, strings, inside, loop = {}, {}, False, None + for _, line in gold.read_asm(path, defines): + s = line.strip() + if re.match(r"newcharmap\s+unown\b", s): + inside = True + continue + if not inside: + continue + if re.match(r"(popc|newcharmap)\b", s): + break + m = re.match(r'DEF\s+(\w+)\s+EQUS\s+"(.*)"$', s) + if m: + strings[m.group(1)] = m.group(2) + continue + m = re.match(r"for\s+(\w+),\s*STRLEN\(#(\w+)\)\s*$", s) + if m: + loop = (m.group(1), strings[m.group(2)]) + continue + if re.match(r"endr\s*$", s): + loop = None + continue + m = re.match( + r"charmap\s+STRSLICE\(#(\w+),\s*\w+,\s*\w+\s*\+\s*1\),\s*(.+)$", s) + if m and loop: + for index, char in enumerate(strings[m.group(1)]): + out[str(_rgbds_int(m.group(2), index))] = char + continue + m = re.match(r'charmap\s+"(.*)",\s*(\$[0-9a-fA-F]+)$', s) + if m: + out[str(int(m.group(2)[1:], 16))] = m.group(1) + if not out: + raise SystemExit("charmap.asm has no `unown` charmap") + return out + + +def generate(pokecrystal, symbols_path): + data = gold.generate( + pokecrystal, symbols_path, + defines=CRYSTAL_ASM_DEFINES, + required=CRYSTAL_REQUIRED_SYMBOLS, + sha1=CANONICAL_CRYSTAL_SHA1, + anim_labels=True) + # Crystal renumbers the block: 162 flags to Gold's 93, so a consumer that + # hardcodes Gold's indices reads the wrong flag. + data["constants"]["engineFlagOrder"] = gold.parse_const_block( + os.path.join(pokecrystal, "constants", "engine_flags.asm"), + defines=CRYSTAL_ASM_DEFINES) + data["unownCharmap"] = unown_charmap(pokecrystal, CRYSTAL_ASM_DEFINES) + return data + + +def find_pokecrystal(): + for candidate in DEFAULT_POKECRYSTAL_CANDIDATES: + if os.path.isfile(os.path.join(candidate, "main.asm")): + return candidate + return DEFAULT_POKECRYSTAL_CANDIDATES[-1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pokecrystal", default=find_pokecrystal()) + parser.add_argument("--symbols", default=DEFAULT_SYMBOLS) + parser.add_argument("--out", default=DEFAULT_OUT) + args = parser.parse_args() + + pokecrystal = os.path.abspath(args.pokecrystal) + if not os.path.isfile(os.path.join(pokecrystal, "main.asm")): + raise SystemExit(f"{pokecrystal} is not a pokecrystal checkout") + data = generate(pokecrystal, os.path.abspath(args.symbols)) + with open(args.out, "w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + print(f"wrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 074e7811..5f48fb3b 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -69,8 +69,14 @@ def strip_comment(line): return "".join(out).rstrip() -def read_asm(path): - """Read an asm file as (lineno, text), comments stripped, IF resolved.""" +def read_asm(path, defines=None): + """Read an asm file as (lineno, text), comments stripped, IF resolved. + + `defines` overrides ASM_DEFINES for another tree; pokecrystal's retail + v1.0 target passes no -D at all (../pokecrystal/Makefile:129). + """ + if defines is None: + defines = ASM_DEFINES lines = [] stack = [] # stack of [taking, condition_known] with open(path, encoding="utf-8") as f: @@ -79,7 +85,7 @@ def read_asm(path): s = line.strip() m = re.match(r"IF\s+(!)?DEF\((\w+)\)\s*$", s, re.IGNORECASE) if m: - defined = m.group(2) in ASM_DEFINES + defined = m.group(2) in defines taking = (not defined) if m.group(1) else defined stack.append([taking, True]) continue @@ -115,7 +121,7 @@ def parse_number(tok): return -val if neg else val -def parse_const_block(path, stop_at=None): +def parse_const_block(path, stop_at=None, defines=None): """Parse a linear const_def/const/const_skip block into an ordered list. Index i of the returned list is the constant's value (None for a gap); @@ -123,7 +129,7 @@ def parse_const_block(path, stop_at=None): """ names = [] value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if not s: continue @@ -153,7 +159,7 @@ def parse_const_block(path, stop_at=None): return names -def parse_const_block_at(path, first_const, stop_at=None): +def parse_const_block_at(path, first_const, stop_at=None, defines=None): """Parse the one const_def block whose first `const` is `first_const`. parse_const_block walks a file linearly and a second `const_def` in the @@ -166,7 +172,7 @@ def parse_const_block_at(path, first_const, stop_at=None): names = [] value = None started = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if not s: continue @@ -201,7 +207,7 @@ def parse_const_block_at(path, first_const, stop_at=None): return names -def parse_prefixed_consts(path, prefixes, exact=()): +def parse_prefixed_consts(path, prefixes, exact=(), defines=None): """Ordered const names from a file, filtered by prefix (mixed blocks). map_data_constants.asm and friends stack several unrelated const_def @@ -210,7 +216,7 @@ def parse_prefixed_consts(path, prefixes, exact=()): even when they carry no shared prefix (TOWN, BALL, ...). """ out = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"const\s+(\w+)", line.strip()) if not m: continue @@ -220,7 +226,7 @@ def parse_prefixed_consts(path, prefixes, exact=()): return out -def parse_sparse_consts(path, prefixes): +def parse_sparse_consts(path, prefixes, defines=None): """Ordered const names where index IS the const value (sparse blocks). parse_prefixed_consts packs a block densely, which is wrong for a block @@ -233,7 +239,7 @@ def parse_sparse_consts(path, prefixes): """ out = [] value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const_def(?:\s+(\d+))?\s*$", s) if m: @@ -259,7 +265,7 @@ def parse_sparse_consts(path, prefixes): return out -def extract_trainer_classes(pokegold): +def extract_trainer_classes(pokegold, defines=None): """Ordered trainer class names (constants/trainer_constants.asm). `trainerclass NAME` bumps its own counter and resets the const_def used @@ -271,7 +277,7 @@ def extract_trainer_classes(pokegold): classes = [] members = {} current = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"trainerclass\s+(\w+)", s) if m: @@ -287,7 +293,7 @@ def extract_trainer_classes(pokegold): return classes, members -def extract_items(pokegold): +def extract_items(pokegold, defines=None): """Ordered item ids, and how many of them ItemNames actually covers. parse_const_block cannot do this file: after NUM_ITEMS the TM and HM items @@ -304,7 +310,7 @@ def extract_items(pokegold): order = [] name_count = None in_macro = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() # The add_tm / add_hm macro bodies contain `const TM_\1` themselves; # counting those would insert a phantom "TM_" item before the real run. @@ -340,7 +346,7 @@ def extract_items(pokegold): return order[1:], name_count - 1 -def extract_specials(pokegold): +def extract_specials(pokegold, defines=None): """Ordered SpecialsPointers labels (data/events/special_pointers.asm). The `special` script command carries an index into this table, not a name, @@ -350,7 +356,7 @@ def extract_specials(pokegold): path = os.path.join(pokegold, "data/events/special_pointers.asm") names = [] in_macro = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if re.match(r"MACRO\b", s): in_macro = True @@ -368,11 +374,11 @@ def extract_specials(pokegold): return names -def extract_std_scripts(pokegold): +def extract_std_scripts(pokegold, defines=None): """Ordered StdScripts labels (engine/events/std_scripts.asm).""" path = os.path.join(pokegold, "engine/events/std_scripts.asm") names = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"add_stdscript\s+(\w+)", line.strip()) if m: names.append(m.group(1)) @@ -381,14 +387,14 @@ def extract_std_scripts(pokegold): return names -def extract_map_groups(pokegold): +def extract_map_groups(pokegold, defines=None): """Parse constants/map_constants.asm's newgroup/map_const/endgroup.""" path = os.path.join(pokegold, "constants/map_constants.asm") order = [] groups = {} group = 0 map_index = 0 - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if re.match(r"newgroup\s+\w+", s): group += 1 @@ -411,12 +417,12 @@ def extract_map_groups(pokegold): return order, groups -def extract_types(pokegold): +def extract_types(pokegold, defines=None): """Type constants are physical IDs, a gap, then special IDs (Gen 1-style).""" path = os.path.join(pokegold, "constants/type_constants.asm") types = {} value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) if m: @@ -435,12 +441,14 @@ def extract_types(pokegold): return types -def charmap(pokegold): +def charmap(pokegold, defines=None): """Byte -> text charmap, same shape as make_rom_manifest.charmap.""" expansions = { "": ".", "": "{LV}", "": "{ID}", + # ../pokecrystal/constants/charmap.asm:6 -- $14 is "" in English + "": "", # Compression bytes: the cart stores one byte and PlaceString expands # it into several glyphs. The font sheet only starts at $60, so these # three have no tile of their own and MUST be expanded here or the @@ -453,10 +461,15 @@ def charmap(pokegold): } out = {} path = os.path.join(pokegold, "constants/charmap.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): + stripped = line.strip() + # ../pokecrystal/constants/charmap.asm:423,433 -- the unown and ascii + # charmaps are separate RGBDS charmaps, not more rows of the main one. + if re.match(r"(pushc|newcharmap)\b", stripped): + break m = re.match( r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$[0-9a-fA-F]+)', - line.strip()) + stripped) if not m: continue value = int(m.group(2)[1:], 16) @@ -478,22 +491,22 @@ def species_label(species_id): return "".join(part.capitalize() for part in parts) -def pokemon_names(pokegold): +def pokemon_names(pokegold, defines=None): """PokemonNames dname entries, in declared (dex) order.""" names = [] path = os.path.join(pokegold, "data/pokemon/names.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r'dname\s+"([^"]*)"', line.strip()) if m: names.append(m.group(1)) return names -def music_order(pokegold): +def music_order(pokegold, defines=None): """Music_* labels in MUSIC_* id order from audio/music_pointers.asm.""" path = os.path.join(pokegold, "audio/music_pointers.asm") order = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"dba\s+(Music_\w+)\s*$", line.strip()) if m: order.append(m.group(1)) @@ -502,11 +515,11 @@ def music_order(pokegold): return order -def sfx_order(pokegold): +def sfx_order(pokegold, defines=None): """Sfx_* labels in SFX_* id order from audio/sfx_pointers.asm.""" path = os.path.join(pokegold, "audio/sfx_pointers.asm") order = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"dba\s+(Sfx_\w+)\s*$", line.strip()) if m: order.append(m.group(1)) @@ -515,7 +528,27 @@ def sfx_order(pokegold): return order -def pokemon_assets(pokegold, species_order, symbols): +# ../pokecrystal/main.asm:425-448 -- the four per-species pic-animation +# tables; field name in the manifest, symbol suffix in pokecrystal.sym. +ANIM_LABEL_SUFFIXES = ( + ("animLabel", "Animation"), + ("idleLabel", "AnimationIdle"), + ("bitmaskLabel", "Bitmasks"), + ("framesLabel", "Frames"), +) + + +def _anim_labels(base, symbols): + """{animLabel/idleLabel/bitmaskLabel/framesLabel: label or None}.""" + out = {} + for field, suffix in ANIM_LABEL_SUFFIXES: + label = base + suffix + out[field] = label if label in symbols.by_name else None + return out + + +def pokemon_assets(pokegold, species_order, symbols, defines=None, + anim_labels=False): """{species: {id, name, front, back, frontLabel, backLabel}}. Unown shares one pic per letter through UnownPicPointers (its @@ -523,11 +556,15 @@ def pokemon_assets(pokegold, species_order, symbols): per-species Frontpic/Backpic label, so its front/back/labels are left null; the extractor's own Unown handling (if any) goes through UnownPicPointers directly instead of this table. + + `anim_labels` adds the four pic-animation labels per species; off by + default because pokegold.sym carries none of them. """ - names = pokemon_names(pokegold) + names = pokemon_names(pokegold, defines) assets = {} for index, species in enumerate(species_order): name = names[index] if index < len(names) else species + base = species_label(species) if species == "UNOWN": # No per-species pic, but it does have a dex entry like anything # else, so the #DEX screen can still read it. @@ -537,8 +574,9 @@ def pokemon_assets(pokegold, species_order, symbols): "frontLabel": None, "backLabel": None, "dexLabel": "UnownPokedexEntry", } + if anim_labels: + assets[species].update(_anim_labels(base, symbols)) continue - base = species_label(species) front_label, back_label = base + "Frontpic", base + "Backpic" if front_label not in symbols.by_name or back_label not in symbols.by_name: raise ValueError( @@ -554,6 +592,8 @@ def pokemon_assets(pokegold, species_order, symbols): "frontLabel": front_label, "backLabel": back_label, "dexLabel": dex_label if dex_label in symbols.by_name else None, } + if anim_labels: + assets[species].update(_anim_labels(base, symbols)) return assets @@ -927,7 +967,7 @@ TEXT_SOURCES = ( ) -def text_labels(pokegold): +def text_labels(pokegold, defines=None): """Every text label in TEXT_SOURCES, in sorted order. Unlike Gen 1 there is no `dynamic` map beside this. pokered's decoder is @@ -939,7 +979,7 @@ def text_labels(pokegold): for name in TEXT_SOURCES: path = os.path.join(pokegold, "data/text", name) pending = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): stripped = line.strip() if not stripped: continue @@ -959,9 +999,11 @@ def text_labels(pokegold): def embedded_symbols(symbols, pokemon_labels, song_labels=(), - text_label_names=()): + text_label_names=(), required=None): """Resolve REQUIRED_SYMBOLS + pic labels + songs + text labels.""" - names = (set(REQUIRED_SYMBOLS) | set(pokemon_labels) + if required is None: + required = REQUIRED_SYMBOLS + names = (set(required) | set(pokemon_labels) | set(song_labels) | set(text_label_names)) for symbol_name in symbols.by_name: # Pokedex entries are split across four banks and the game derives the @@ -985,23 +1027,24 @@ def embedded_symbols(symbols, pokemon_labels, song_labels=(), } -def generate(pokegold, symbols_path): +def generate(pokegold, symbols_path, defines=None, required=None, + sha1=None, anim_labels=False): symbols = SymbolTable(symbols_path) species = parse_const_block( os.path.join(pokegold, "constants/pokemon_constants.asm"), - stop_at="NUM_POKEMON") + stop_at="NUM_POKEMON", defines=defines) species_order = [n or "UNUSED" for n in species[1:]] - map_order, map_groups = extract_map_groups(pokegold) + map_order, map_groups = extract_map_groups(pokegold, defines) tilesets = [n for n in parse_const_block( os.path.join(pokegold, "constants/tileset_constants.asm"), - stop_at="NUM_TILESETS") if n] + stop_at="NUM_TILESETS", defines=defines) if n] moves = parse_const_block( os.path.join(pokegold, "constants/move_constants.asm"), - stop_at="NUM_ATTACKS") + stop_at="NUM_ATTACKS", defines=defines) move_order = [n or "UNUSED" for n in moves[1:]] # sprite_constants.asm stacks the ids of two different tables. $01.. @@ -1019,26 +1062,29 @@ def generate(pokegold, symbols_path): # wVariableSprites, so naming them here would let World:resolveSprite hand # a slot id back as though it were something that could be drawn. sprite_path = os.path.join(pokegold, "constants/sprite_constants.asm") - sprites = parse_const_block(sprite_path, stop_at="NUM_POKEMON_SPRITES") + sprites = parse_const_block( + sprite_path, stop_at="NUM_POKEMON_SPRITES", defines=defines) sprite_order = [n or "UNUSED" for n in sprites[1:]] # The two DEFs the block itself ends its halves on. The $60..$7f hole # between them stays in sprite_order as UNUSED rows so the ids line up. num_overworld_sprites = len( - parse_const_block(sprite_path, stop_at="NUM_OVERWORLD_SPRITES")) - 1 + parse_const_block( + sprite_path, stop_at="NUM_OVERWORLD_SPRITES", + defines=defines)) - 1 sprite_pokemon = sprites.index("SPRITE_UNOWN") if len(sprites) - sprite_pokemon != 35: raise SystemExit( f"{sprite_path}: expected 35 SpriteMons ids, got " f"{len(sprites) - sprite_pokemon}") - types = extract_types(pokegold) + types = extract_types(pokegold, defines) # Environment (1-based), palette (0-based), fish-group (0-based) name # tables -- scraped by prefix so the mixed const_def blocks in # map_data_constants.asm cannot collide with each other. environments, palettes, fish_groups, spawns = [], [], [], [] path = os.path.join(pokegold, "constants/map_data_constants.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const\s+(\w+)", s) if not m: @@ -1060,7 +1106,8 @@ def generate(pokegold, symbols_path): # Moves' effect byte into one of these names so the battle engine can # switch on a readable id instead of a raw number. move_effects = parse_const_block( - os.path.join(pokegold, "constants/move_effect_constants.asm")) + os.path.join(pokegold, "constants/move_effect_constants.asm"), + defines=defines) move_effect_order = [n or "EFFECT_UNUSED" for n in move_effects] # Battle animations. constants/battle_anim_constants.asm stacks nine @@ -1070,55 +1117,66 @@ def generate(pokegold, symbols_path): # framesets, OAM sets, GFX sheets) plus the BG-effect and palette enums a # disassembled animation refers to by number. battle_anim = os.path.join(pokegold, "constants/battle_anim_constants.asm") - battle_anim_objects = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_OBJ_",)) - battle_anim_funcs = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_FUNC_",)) + battle_anim_objects = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_OBJ_",), defines=defines) + battle_anim_funcs = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_FUNC_",), defines=defines) battle_anim_framesets = parse_prefixed_consts( - battle_anim, ("BATTLE_ANIM_FRAMESET_",)) - battle_anim_oamsets = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_OAMSET_",)) + battle_anim, ("BATTLE_ANIM_FRAMESET_",), defines=defines) + battle_anim_oamsets = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_OAMSET_",), defines=defines) # BATTLE_ANIM_GFX_* is the one block here that does not start at zero # (`const_def 1`, because AnimObjGFX row 0 is the empty AnimObj00GFX). # A placeholder in front keeps every list in this group indexable as # value + 1, so the extractor never has to remember which is which. battle_anim_gfx = ["BATTLE_ANIM_GFX_NONE"] + parse_prefixed_consts( - battle_anim, ("BATTLE_ANIM_GFX_",)) - battle_bg_effects = parse_prefixed_consts(battle_anim, ("BATTLE_BG_EFFECT_",)) + battle_anim, ("BATTLE_ANIM_GFX_",), defines=defines) + battle_bg_effects = parse_prefixed_consts( + battle_anim, ("BATTLE_BG_EFFECT_",), defines=defines) # Two separate blocks, each starting at zero: a BG palette 4 and an OBJ # palette 4 are different colours, so they cannot share one list. - battle_anim_bg_pals = parse_prefixed_consts(battle_anim, ("PAL_BATTLE_BG_",)) - battle_anim_ob_pals = parse_prefixed_consts(battle_anim, ("PAL_BATTLE_OB_",)) + battle_anim_bg_pals = parse_prefixed_consts( + battle_anim, ("PAL_BATTLE_BG_",), defines=defines) + battle_anim_ob_pals = parse_prefixed_consts( + battle_anim, ("PAL_BATTLE_OB_",), defines=defines) item_data = os.path.join(pokegold, "constants/item_data_constants.asm") # Pocket ids are 0-based (ITEM, KEY_ITEM, BALL, TM_HM); the ITEM_* / # ITEMMENU_* / HELD_* blocks share the file, hence the prefix scrape. pocket_order = parse_prefixed_consts( - item_data, (), exact=("ITEM", "KEY_ITEM", "BALL", "TM_HM")) - item_menu_order = parse_prefixed_consts(item_data, ("ITEMMENU_",)) + item_data, (), exact=("ITEM", "KEY_ITEM", "BALL", "TM_HM"), + defines=defines) + item_menu_order = parse_prefixed_consts( + item_data, ("ITEMMENU_",), defines=defines) # The HELD_* block is sparse (const_skip / const_next), so the index of # this list must BE the ItemAttributes effect byte or every held effect # past HELD_CLEANSE_TAG lands on the wrong item. - held_effect_order = parse_sparse_consts(item_data, ("HELD_",)) + held_effect_order = parse_sparse_consts( + item_data, ("HELD_",), defines=defines) mon_data = os.path.join(pokegold, "constants/pokemon_data_constants.asm") - growth_order = parse_prefixed_consts(mon_data, ("GROWTH_",)) - egg_group_order = parse_prefixed_consts(mon_data, ("EGG_",)) - evolve_order = parse_prefixed_consts(mon_data, ("EVOLVE_",)) + growth_order = parse_prefixed_consts(mon_data, ("GROWTH_",), defines=defines) + egg_group_order = parse_prefixed_consts(mon_data, ("EGG_",), defines=defines) + evolve_order = parse_prefixed_consts(mon_data, ("EVOLVE_",), defines=defines) # Map callbacks (constants/map_setup_constants.asm). That block is # `const_def 1`, so MAPCALLBACK_TILES is 1 and index 0 of this list is a # placeholder -- the same "value + 1" indexing BATTLE_ANIM_GFX_* uses. map_setup = os.path.join(pokegold, "constants/map_setup_constants.asm") map_callback_order = ["MAPCALLBACK_NONE"] + parse_prefixed_consts( - map_setup, ("MAPCALLBACK_",)) + map_setup, ("MAPCALLBACK_",), defines=defines) # constants/script_constants.asm stacks a dozen unrelated blocks, so each # of these is found by the name its own block opens with. script_consts = os.path.join(pokegold, "constants/script_constants.asm") cmd_queue_order = parse_const_block_at( - script_consts, "CMDQUEUE_NULL", stop_at="NUM_CMDQUEUE_TYPES") + script_consts, "CMDQUEUE_NULL", stop_at="NUM_CMDQUEUE_TYPES", + defines=defines) floor_order = parse_const_block_at( - script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS") + script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS", defines=defines) deco_desc_order = parse_const_block_at( - script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS") + script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS", + defines=defines) # The phone. PHONE_* doubles as the PhoneContacts row index and carries # four const_skip holes that are real rows (the wrong-number fillers), so @@ -1129,33 +1187,41 @@ def generate(pokegold, symbols_path): # every contact past a hole four rows down its own table. phone_contact_order = [ n or "PHONE_UNUSED" for n in parse_const_block_at( - phone_consts, "PHONE_00", stop_at="NUM_PHONE_CONTACTS")] + phone_consts, "PHONE_00", stop_at="NUM_PHONE_CONTACTS", + defines=defines)] special_call_order = parse_const_block_at( - phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS") + phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS", + defines=defines) npc_trade = os.path.join(pokegold, "constants/npc_trade_constants.asm") - trade_gender_order = parse_prefixed_consts(npc_trade, ("TRADE_GENDER_",)) - trade_dialog_order = parse_prefixed_consts(npc_trade, ("TRADE_DIALOGSET_",)) + trade_gender_order = parse_prefixed_consts( + npc_trade, ("TRADE_GENDER_",), defines=defines) + trade_dialog_order = parse_prefixed_consts( + npc_trade, ("TRADE_DIALOGSET_",), defines=defines) - trainer_classes, trainer_members = extract_trainer_classes(pokegold) + trainer_classes, trainer_members = extract_trainer_classes( + pokegold, defines) trainer_types = parse_prefixed_consts( os.path.join(pokegold, "constants/trainer_data_constants.asm"), - ("TRAINERTYPE_",)) + ("TRAINERTYPE_",), defines=defines) landmarks = parse_const_block( os.path.join(pokegold, "constants/landmark_constants.asm"), - stop_at="NUM_LANDMARKS") + stop_at="NUM_LANDMARKS", defines=defines) landmark_order = [n or "UNUSED" for n in landmarks] icons = parse_prefixed_consts( - os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",)) + os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",), + defines=defines) - tree_sets = parse_prefixed_consts(mon_data, ("TREEMON_SET_",)) + tree_sets = parse_prefixed_consts( + mon_data, ("TREEMON_SET_",), defines=defines) - std_scripts = extract_std_scripts(pokegold) - specials = extract_specials(pokegold) + std_scripts = extract_std_scripts(pokegold, defines) + specials = extract_specials(pokegold, defines) - assets = pokemon_assets(pokegold, species_order, symbols) + assets = pokemon_assets(pokegold, species_order, symbols, defines, + anim_labels=anim_labels) pokemon_labels = [] for asset in assets.values(): if asset["frontLabel"]: @@ -1163,19 +1229,19 @@ def generate(pokegold, symbols_path): if asset["backLabel"]: pokemon_labels.append(asset["backLabel"]) - songs = music_order(pokegold) - text_label_names = text_labels(pokegold) - sfx = sfx_order(pokegold) + songs = music_order(pokegold, defines) + text_label_names = text_labels(pokegold, defines) + sfx = sfx_order(pokegold, defines) # Index 0 is NO_ITEM, so the parsed list is already 1-based on item id. # ItemNames only has rows for the first `item_name_count` of them; the TM # and HM items past that are named from their TM number instead. - item_order, item_name_count = extract_items(pokegold) + item_order, item_name_count = extract_items(pokegold, defines) data = { "format": 3, "generation": 2, - "romSha1": CANONICAL_GOLD_SHA1, + "romSha1": sha1 or CANONICAL_GOLD_SHA1, "constants": { "source": "pret/pokegold constants/*.asm", "speciesOrder": species_order, @@ -1259,7 +1325,7 @@ def generate(pokegold, symbols_path): # Label -> decoded string is built at import time from these, the # same way Gen 1 builds data/generated/text.lua from its own list. "text": {"labels": text_label_names}, - "charmap": charmap(pokegold), + "charmap": charmap(pokegold, defines), "fontCharmap": font_extract.parse_charmap(pokegold), "pokemonAssets": assets, # Per-map metadata (group/map/width/height/name). RomExtractorGen2 @@ -1268,7 +1334,7 @@ def generate(pokegold, symbols_path): "tilesets": {name: {} for name in tilesets}, } data["symbols"] = embedded_symbols( - symbols, pokemon_labels, songs, text_label_names) + symbols, pokemon_labels, songs, text_label_names, required=required) return data diff --git a/tools/modkit.py b/tools/modkit.py index 6b934c61..bae76b15 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -741,7 +741,7 @@ def imported_data_dir(repo): return path if _generated_data_dir_ok(path) else None version = (os.environ.get("POKEPORT_VERSION") or "red").lower() - if version not in ("red", "blue", "yellow", "gold", "silver"): + if version not in ("red", "blue", "yellow", "gold", "silver", "crystal"): version = "red" candidates = [ @@ -2404,8 +2404,8 @@ UPVALUE_CALL = re.compile( UPVALUE_ARGS = re.compile( r"""^\s*([A-Za-z_]\w*)\s*\.\s*(\w+)\s*,\s*["'](\w+)["']""") VERSION_MATCH = re.compile( - r"""[=~]=\s*["'](red|blue|yellow|gold|silver)["']""" - r"""|["'](red|blue|yellow|gold|silver)["']\s*[=~]=""") + r"""[=~]=\s*["'](red|blue|yellow|gold|silver|crystal)["']""" + r"""|["'](red|blue|yellow|gold|silver|crystal)["']\s*[=~]=""") def _line_of(body, offset): diff --git a/tools/rom_data.py b/tools/rom_data.py index 2e9375d5..bfb3d084 100644 --- a/tools/rom_data.py +++ b/tools/rom_data.py @@ -14,6 +14,9 @@ CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" # Gold and Silver are Gen 2: 2 MiB carts, twice the size of the Gen 1 ROMs above. CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94" CANONICAL_SILVER_SHA1 = "49b163f7e57702bc939d642a18f591de55d92dae" +# Crystal is the retail international v1.0 build (pret/pokecrystal's default +# `make` target), also 2 MiB. +CANONICAL_CRYSTAL_SHA1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" ROM_BANK_SIZE = 0x4000 diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json new file mode 100644 index 00000000..e1b7609d --- /dev/null +++ b/tools/rom_manifest_crystal.json @@ -0,0 +1,24663 @@ +{ + "charmap": { + "0": "", + "10": "ザ", + "100": "", + "101": "", + "102": "", + "103": "", + "104": "", + "105": "", + "106": "", + "107": "", + "108": "", + "109": "", + "11": "ジ", + "110": "ぃ", + "111": "ぅ", + "112": "", + "113": "", + "114": "“", + "115": "”", + "116": "·", + "117": "…", + "118": "ぁ", + "119": "ぇ", + "12": "ズ", + "120": "ぉ", + "121": "┌", + "122": "─", + "123": "┐", + "124": "│", + "125": "└", + "126": "┘", + "127": " ", + "128": "A", + "129": "B", + "13": "ゼ", + "130": "C", + "131": "D", + "132": "E", + "133": "F", + "134": "G", + "135": "H", + "136": "I", + "137": "J", + "138": "K", + "139": "L", + "14": "ゾ", + "140": "M", + "141": "N", + "142": "O", + "143": "P", + "144": "Q", + "145": "R", + "146": "S", + "147": "T", + "148": "U", + "149": "V", + "15": "ダ", + "150": "W", + "151": "X", + "152": "Y", + "153": "Z", + "154": "(", + "155": ")", + "156": ":", + "157": ";", + "158": "[", + "159": "]", + "16": "ヂ", + "160": "a", + "161": "b", + "162": "c", + "163": "d", + "164": "e", + "165": "f", + "166": "g", + "167": "h", + "168": "i", + "169": "j", + "17": "ヅ", + "170": "k", + "171": "l", + "172": "m", + "173": "n", + "174": "o", + "175": "p", + "176": "q", + "177": "r", + "178": "s", + "179": "t", + "18": "デ", + "180": "u", + "181": "v", + "182": "w", + "183": "x", + "184": "y", + "185": "z", + "186": "こ", + "187": "さ", + "188": "し", + "189": "す", + "19": "ド", + "190": "せ", + "191": "そ", + "192": "Ä", + "193": "Ö", + "194": "Ü", + "195": "ä", + "196": "ö", + "197": "ü", + "198": "に", + "199": "ぬ", + "20": "", + "200": "ね", + "201": "の", + "202": "は", + "203": "ひ", + "204": "ふ", + "205": "へ", + "206": "ほ", + "207": "ま", + "208": "'d", + "209": "'l", + "21": "", + "210": "'m", + "211": "'r", + "212": "'s", + "213": "'t", + "214": "'v", + "215": "ら", + "216": "り", + "217": "る", + "218": "れ", + "219": "ろ", + "22": "", + "220": "わ", + "221": "を", + "222": "ん", + "223": "←", + "224": "'", + "225": "", + "226": "", + "227": "-", + "228": "゚", + "229": "゙", + "230": "?", + "231": "!", + "232": ".", + "233": "&", + "234": "é", + "235": "→", + "236": "▷", + "237": "▶", + "238": "▼", + "239": "♂", + "24": "", + "240": "¥", + "241": "×", + "242": ".", + "243": "/", + "244": ",", + "245": "♀", + "246": "0", + "247": "1", + "248": "2", + "249": "3", + "25": "バ", + "250": "4", + "251": "5", + "252": "6", + "253": "7", + "254": "8", + "255": "9", + "26": "ビ", + "27": "ブ", + "28": "ボ", + "29": "", + "30": "", + "31": "", + "34": "", + "35": "", + "36": "", + "37": "", + "38": "が", + "39": "ぎ", + "40": "ぐ", + "41": "げ", + "42": "ご", + "43": "ざ", + "44": "じ", + "45": "ず", + "46": "ぜ", + "47": "ぞ", + "48": "だ", + "49": "ぢ", + "5": "ガ", + "50": "づ", + "51": "で", + "52": "ど", + "53": "", + "54": "", + "55": "", + "56": "", + "57": "", + "58": "ば", + "59": "び", + "6": "ギ", + "60": "ぶ", + "61": "べ", + "62": "ぼ", + "63": "", + "64": "パ", + "65": "ピ", + "66": "プ", + "67": "ポ", + "68": "ぱ", + "69": "ぴ", + "7": "グ", + "70": "ぷ", + "71": "ぺ", + "72": "ぽ", + "73": "", + "74": "", + "75": "<_CONT>", + "76": "", + "78": "", + "79": "", + "8": "ゲ", + "80": "@", + "81": "", + "82": "", + "83": "", + "84": "POKé", + "85": "", + "86": "<……>", + "87": "", + "88": "", + "89": "", + "9": "ゴ", + "90": "", + "91": "", + "92": "", + "93": "", + "94": "", + "95": "", + "96": "", + "97": "", + "98": "", + "99": "" + }, + "constants": { + "battleAnimBgPaletteOrder": [ + "PAL_BATTLE_BG_PLAYER", + "PAL_BATTLE_BG_ENEMY", + "PAL_BATTLE_BG_ENEMY_HP", + "PAL_BATTLE_BG_PLAYER_HP", + "PAL_BATTLE_BG_EXP", + "PAL_BATTLE_BG_5", + "PAL_BATTLE_BG_6", + "PAL_BATTLE_BG_TEXT" + ], + "battleAnimFramesetOrder": [ + "BATTLE_ANIM_FRAMESET_HIT_BIG", + "BATTLE_ANIM_FRAMESET_HIT", + "BATTLE_ANIM_FRAMESET_HIT_SMALL", + "BATTLE_ANIM_FRAMESET_PUNCH", + "BATTLE_ANIM_FRAMESET_KICK", + "BATTLE_ANIM_FRAMESET_PALM", + "BATTLE_ANIM_FRAMESET_FANG", + "BATTLE_ANIM_FRAMESET_PUNCH_SHAKE", + "BATTLE_ANIM_FRAMESET_BALL_POOF", + "BATTLE_ANIM_FRAMESET_POKE_BALL_1", + "BATTLE_ANIM_FRAMESET_POKE_BALL_2", + "BATTLE_ANIM_FRAMESET_POKE_BALL_3", + "BATTLE_ANIM_FRAMESET_POKE_BALL_4", + "BATTLE_ANIM_FRAMESET_POKE_BALL_5", + "BATTLE_ANIM_FRAMESET_DRAGON_RAGE", + "BATTLE_ANIM_FRAMESET_FLAMETHROWER", + "BATTLE_ANIM_FRAMESET_EMBER", + "BATTLE_ANIM_FRAMESET_BURNED", + "BATTLE_ANIM_FRAMESET_BLIZZARD", + "BATTLE_ANIM_FRAMESET_ICE", + "BATTLE_ANIM_FRAMESET_ICE_BEAM", + "BATTLE_ANIM_FRAMESET_POWDER_SNOW", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2", + "BATTLE_ANIM_FRAMESET_EXPLOSION", + "BATTLE_ANIM_FRAMESET_BIG_ROCK", + "BATTLE_ANIM_FRAMESET_SMALL_ROCK", + "BATTLE_ANIM_FRAMESET_STRENGTH", + "BATTLE_ANIM_FRAMESET_SKULL_CROSSBONE", + "BATTLE_ANIM_FRAMESET_ACID", + "BATTLE_ANIM_FRAMESET_POISON_DROPLET_UNUSED", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST", + "BATTLE_ANIM_FRAMESET_SMALL_BUBBLE", + "BATTLE_ANIM_FRAMESET_PULSING_BUBBLE", + "BATTLE_ANIM_FRAMESET_SURF", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_2", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_3", + "BATTLE_ANIM_FRAMESET_WATER_GUN_1", + "BATTLE_ANIM_FRAMESET_WATER_GUN_2", + "BATTLE_ANIM_FRAMESET_WATER_GUN_3", + "BATTLE_ANIM_FRAMESET_HYDRO_PUMP", + "BATTLE_ANIM_FRAMESET_POWDER", + "BATTLE_ANIM_FRAMESET_BEAM", + "BATTLE_ANIM_FRAMESET_BEAM_TIP", + "BATTLE_ANIM_FRAMESET_ICE_BUILDUP", + "BATTLE_ANIM_FRAMESET_FROZEN", + "BATTLE_ANIM_FRAMESET_CIRCLING_SPARKLE", + "BATTLE_ANIM_FRAMESET_THUNDER_CENTER", + "BATTLE_ANIM_FRAMESET_THUNDER_LEFT", + "BATTLE_ANIM_FRAMESET_THUNDER_RIGHT", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_DISABLE", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_CORE", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_CORE", + "BATTLE_ANIM_FRAMESET_CLAMP", + "BATTLE_ANIM_FRAMESET_CLAMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_BITE_1", + "BATTLE_ANIM_FRAMESET_BITE_2", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_UP_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_1", + "BATTLE_ANIM_FRAMESET_ABSORB_CENTER", + "BATTLE_ANIM_FRAMESET_GUST", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_1", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_2", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_1", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_2", + "BATTLE_ANIM_FRAMESET_SONICBOOM_JP", + "BATTLE_ANIM_FRAMESET_WARP", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_2", + "BATTLE_ANIM_FRAMESET_EGG", + "BATTLE_ANIM_FRAMESET_EGG_WOBBLE", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM", + "BATTLE_ANIM_FRAMESET_FOCUS", + "BATTLE_ANIM_FRAMESET_BIND_1", + "BATTLE_ANIM_FRAMESET_BIND_2", + "BATTLE_ANIM_FRAMESET_BIND_3", + "BATTLE_ANIM_FRAMESET_BIND_4", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_1", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_2", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_3", + "BATTLE_ANIM_FRAMESET_SOUND_1", + "BATTLE_ANIM_FRAMESET_SOUND_2", + "BATTLE_ANIM_FRAMESET_SOUND_3", + "BATTLE_ANIM_FRAMESET_WAVE", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_2", + "BATTLE_ANIM_FRAMESET_LEER", + "BATTLE_ANIM_FRAMESET_REFLECT", + "BATTLE_ANIM_FRAMESET_CHICK_1", + "BATTLE_ANIM_FRAMESET_CHICK_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_1", + "BATTLE_ANIM_FRAMESET_AMNESIA_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_3", + "BATTLE_ANIM_FRAMESET_ASLEEP", + "BATTLE_ANIM_FRAMESET_DIG_SAND", + "BATTLE_ANIM_FRAMESET_DIG_PILE", + "BATTLE_ANIM_FRAMESET_SAND", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_1", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_2", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_3", + "BATTLE_ANIM_FRAMESET_PARALYZED", + "BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED", + "BATTLE_ANIM_FRAMESET_HAZE", + "BATTLE_ANIM_FRAMESET_MIST", + "BATTLE_ANIM_FRAMESET_HORN", + "BATTLE_ANIM_FRAMESET_NEEDLE", + "BATTLE_ANIM_FRAMESET_FLOWER", + "BATTLE_ANIM_FRAMESET_BARRAGE_BALL", + "BATTLE_ANIM_FRAMESET_PAY_DAY", + "BATTLE_ANIM_FRAMESET_HEART", + "BATTLE_ANIM_FRAMESET_SPINNING_BONE", + "BATTLE_ANIM_FRAMESET_STAR", + "BATTLE_ANIM_FRAMESET_SPOON", + "BATTLE_ANIM_FRAMESET_SPARKLE", + "BATTLE_ANIM_FRAMESET_SKY_ATTACK", + "BATTLE_ANIM_FRAMESET_LICK", + "BATTLE_ANIM_FRAMESET_WITHDRAW_SHELL", + "BATTLE_ANIM_FRAMESET_SHRINKING_CHARGE_ORB", + "BATTLE_ANIM_FRAMESET_CONVERSION", + "BATTLE_ANIM_FRAMESET_SWORD", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_1", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_2", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_3", + "BATTLE_ANIM_FRAMESET_SEISMIC_TOSS", + "BATTLE_ANIM_FRAMESET_SHARPEN", + "BATTLE_ANIM_FRAMESET_DEFENSE_CURL", + "BATTLE_ANIM_FRAMESET_METRONOME_HAND", + "BATTLE_ANIM_FRAMESET_AGILITY", + "BATTLE_ANIM_FRAMESET_COTTON", + "BATTLE_ANIM_FRAMESET_MILK_BOTTLE", + "BATTLE_ANIM_FRAMESET_SPIKE", + "BATTLE_ANIM_FRAMESET_ANGER_VEIN", + "BATTLE_ANIM_FRAMESET_HEAL_BELL", + "BATTLE_ANIM_FRAMESET_BATON_PASS", + "BATTLE_ANIM_FRAMESET_LOCK_ON_1", + "BATTLE_ANIM_FRAMESET_LOCK_ON_2", + "BATTLE_ANIM_FRAMESET_LOCK_ON_3", + "BATTLE_ANIM_FRAMESET_LOCK_ON_4", + "BATTLE_ANIM_FRAMESET_MIND_READER_1", + "BATTLE_ANIM_FRAMESET_MIND_READER_2", + "BATTLE_ANIM_FRAMESET_MIND_READER_3", + "BATTLE_ANIM_FRAMESET_MIND_READER_4", + "BATTLE_ANIM_FRAMESET_SAFEGUARD", + "BATTLE_ANIM_FRAMESET_MEGAPHONE_UNUSED", + "BATTLE_ANIM_FRAMESET_ITEM_BAG", + "BATTLE_ANIM_FRAMESET_SPIDER_WEB", + "BATTLE_ANIM_FRAMESET_UNUSED_CAKE", + "BATTLE_ANIM_FRAMESET_IMP", + "BATTLE_ANIM_FRAMESET_IMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_CHERUB", + "BATTLE_ANIM_FRAMESET_PENCIL", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND_FLIPPED", + "BATTLE_ANIM_FRAMESET_DESTINY_BOND", + "BATTLE_ANIM_FRAMESET_MORNING_SUN", + "BATTLE_ANIM_FRAMESET_GLIMMER", + "BATTLE_ANIM_FRAMESET_MOONLIGHT", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_1", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_2", + "BATTLE_ANIM_FRAMESET_ZAP_CANNON", + "BATTLE_ANIM_FRAMESET_CURSE_NAIL", + "BATTLE_ANIM_FRAMESET_FORESIGHT_SHINE", + "BATTLE_ANIM_FRAMESET_RAPID_SPIN", + "BATTLE_ANIM_FRAMESET_SWAGGER", + "BATTLE_ANIM_FRAMESET_MEAN_LOOK", + "BATTLE_ANIM_FRAMESET_UPSIDE_DOWN_PUNCH", + "BATTLE_ANIM_FRAMESET_GROWING_SPARKLE", + "BATTLE_ANIM_FRAMESET_RAIN", + "BATTLE_ANIM_FRAMESET_PSYCH_UP", + "BATTLE_ANIM_FRAMESET_SPIRAL_UNUSED", + "BATTLE_ANIM_FRAMESET_AEROBLAST", + "BATTLE_ANIM_FRAMESET_SANDSTORM", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_1ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_1ROW", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_2ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_2ROW" + ], + "battleAnimFuncOrder": [ + "BATTLE_ANIM_FUNC_NULL", + "BATTLE_ANIM_FUNC_USER_TO_TARGET", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + "BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_DROP", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + "BATTLE_ANIM_FUNC_SHAKE", + "BATTLE_ANIM_FUNC_FIRE_BLAST", + "BATTLE_ANIM_FUNC_RAZOR_LEAF", + "BATTLE_ANIM_FUNC_BUBBLE", + "BATTLE_ANIM_FUNC_SURF", + "BATTLE_ANIM_FUNC_SING", + "BATTLE_ANIM_FUNC_WATER_GUN", + "BATTLE_ANIM_FUNC_EMBER", + "BATTLE_ANIM_FUNC_POWDER", + "BATTLE_ANIM_FUNC_POKEBALL", + "BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + "BATTLE_ANIM_FUNC_RECOVER", + "BATTLE_ANIM_FUNC_THUNDER_WAVE", + "BATTLE_ANIM_FUNC_CLAMP_ENCORE", + "BATTLE_ANIM_FUNC_BITE", + "BATTLE_ANIM_FUNC_SOLAR_BEAM", + "BATTLE_ANIM_FUNC_GUST", + "BATTLE_ANIM_FUNC_RAZOR_WIND", + "BATTLE_ANIM_FUNC_KICK", + "BATTLE_ANIM_FUNC_ABSORB", + "BATTLE_ANIM_FUNC_EGG", + "BATTLE_ANIM_FUNC_MOVE_UP", + "BATTLE_ANIM_FUNC_WRAP", + "BATTLE_ANIM_FUNC_LEECH_SEED", + "BATTLE_ANIM_FUNC_SOUND", + "BATTLE_ANIM_FUNC_CONFUSE_RAY", + "BATTLE_ANIM_FUNC_DIZZY", + "BATTLE_ANIM_FUNC_AMNESIA", + "BATTLE_ANIM_FUNC_FLOAT_UP", + "BATTLE_ANIM_FUNC_DIG", + "BATTLE_ANIM_FUNC_STRING", + "BATTLE_ANIM_FUNC_PARALYZED", + "BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + "BATTLE_ANIM_FUNC_POISON_GAS", + "BATTLE_ANIM_FUNC_HORN", + "BATTLE_ANIM_FUNC_NEEDLE", + "BATTLE_ANIM_FUNC_PETAL_DANCE", + "BATTLE_ANIM_FUNC_THIEF_PAYDAY", + "BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + "BATTLE_ANIM_FUNC_BONEMERANG", + "BATTLE_ANIM_FUNC_SHINY", + "BATTLE_ANIM_FUNC_SKY_ATTACK", + "BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + "BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + "BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + "BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + "BATTLE_ANIM_FUNC_SPEED_LINE", + "BATTLE_ANIM_FUNC_SLUDGE", + "BATTLE_ANIM_FUNC_METRONOME_HAND", + "BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + "BATTLE_ANIM_FUNC_AGILITY", + "BATTLE_ANIM_FUNC_SACRED_FIRE", + "BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + "BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + "BATTLE_ANIM_FUNC_SPIKES", + "BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + "BATTLE_ANIM_FUNC_BATON_PASS", + "BATTLE_ANIM_FUNC_CONVERSION", + "BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + "BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + "BATTLE_ANIM_FUNC_HIDDEN_POWER", + "BATTLE_ANIM_FUNC_CURSE", + "BATTLE_ANIM_FUNC_PERISH_SONG", + "BATTLE_ANIM_FUNC_RAPID_SPIN", + "BATTLE_ANIM_FUNC_BETA_PURSUIT", + "BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + "BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_FUNC_PSYCH_UP", + "BATTLE_ANIM_FUNC_ANCIENT_POWER", + "BATTLE_ANIM_FUNC_ROCK_SMASH", + "BATTLE_ANIM_FUNC_COTTON" + ], + "battleAnimGfxOrder": [ + "BATTLE_ANIM_GFX_NONE", + "BATTLE_ANIM_GFX_HIT", + "BATTLE_ANIM_GFX_CUT", + "BATTLE_ANIM_GFX_FIRE", + "BATTLE_ANIM_GFX_WATER", + "BATTLE_ANIM_GFX_LIGHTNING", + "BATTLE_ANIM_GFX_PLANT", + "BATTLE_ANIM_GFX_SMOKE", + "BATTLE_ANIM_GFX_EXPLOSION", + "BATTLE_ANIM_GFX_ROCKS", + "BATTLE_ANIM_GFX_ICE", + "BATTLE_ANIM_GFX_POKE_BALL", + "BATTLE_ANIM_GFX_POISON", + "BATTLE_ANIM_GFX_BUBBLE", + "BATTLE_ANIM_GFX_NOISE", + "BATTLE_ANIM_GFX_POWDER", + "BATTLE_ANIM_GFX_BEAM", + "BATTLE_ANIM_GFX_SPEED", + "BATTLE_ANIM_GFX_CHARGE", + "BATTLE_ANIM_GFX_WIND", + "BATTLE_ANIM_GFX_WHIP", + "BATTLE_ANIM_GFX_EGG", + "BATTLE_ANIM_GFX_ROPE", + "BATTLE_ANIM_GFX_PSYCHIC", + "BATTLE_ANIM_GFX_REFLECT", + "BATTLE_ANIM_GFX_STATUS", + "BATTLE_ANIM_GFX_SAND", + "BATTLE_ANIM_GFX_WEB", + "BATTLE_ANIM_GFX_HAZE", + "BATTLE_ANIM_GFX_HORN", + "BATTLE_ANIM_GFX_FLOWER", + "BATTLE_ANIM_GFX_MISC", + "BATTLE_ANIM_GFX_SKY_ATTACK", + "BATTLE_ANIM_GFX_GLOBE", + "BATTLE_ANIM_GFX_SHAPES", + "BATTLE_ANIM_GFX_OBJECTS", + "BATTLE_ANIM_GFX_SHINE", + "BATTLE_ANIM_GFX_ANGELS", + "BATTLE_ANIM_GFX_WAVE", + "BATTLE_ANIM_GFX_AEROBLAST", + "BATTLE_ANIM_GFX_PLAYERHEAD", + "BATTLE_ANIM_GFX_ENEMYFEET" + ], + "battleAnimOamsetOrder": [ + "BATTLE_ANIM_OAMSET_00", + "BATTLE_ANIM_OAMSET_01", + "BATTLE_ANIM_OAMSET_02", + "BATTLE_ANIM_OAMSET_03", + "BATTLE_ANIM_OAMSET_04", + "BATTLE_ANIM_OAMSET_05", + "BATTLE_ANIM_OAMSET_06", + "BATTLE_ANIM_OAMSET_07", + "BATTLE_ANIM_OAMSET_08", + "BATTLE_ANIM_OAMSET_09", + "BATTLE_ANIM_OAMSET_0A", + "BATTLE_ANIM_OAMSET_0B", + "BATTLE_ANIM_OAMSET_0C", + "BATTLE_ANIM_OAMSET_0D", + "BATTLE_ANIM_OAMSET_0E", + "BATTLE_ANIM_OAMSET_0F", + "BATTLE_ANIM_OAMSET_10", + "BATTLE_ANIM_OAMSET_11", + "BATTLE_ANIM_OAMSET_12", + "BATTLE_ANIM_OAMSET_13", + "BATTLE_ANIM_OAMSET_14", + "BATTLE_ANIM_OAMSET_15", + "BATTLE_ANIM_OAMSET_16", + "BATTLE_ANIM_OAMSET_17", + "BATTLE_ANIM_OAMSET_18", + "BATTLE_ANIM_OAMSET_19", + "BATTLE_ANIM_OAMSET_1A", + "BATTLE_ANIM_OAMSET_1B", + "BATTLE_ANIM_OAMSET_1C", + "BATTLE_ANIM_OAMSET_1D", + "BATTLE_ANIM_OAMSET_1E", + "BATTLE_ANIM_OAMSET_1F", + "BATTLE_ANIM_OAMSET_20", + "BATTLE_ANIM_OAMSET_21", + "BATTLE_ANIM_OAMSET_22", + "BATTLE_ANIM_OAMSET_23", + "BATTLE_ANIM_OAMSET_24", + "BATTLE_ANIM_OAMSET_25", + "BATTLE_ANIM_OAMSET_26", + "BATTLE_ANIM_OAMSET_27", + "BATTLE_ANIM_OAMSET_28", + "BATTLE_ANIM_OAMSET_29", + "BATTLE_ANIM_OAMSET_2A", + "BATTLE_ANIM_OAMSET_2B", + "BATTLE_ANIM_OAMSET_2C", + "BATTLE_ANIM_OAMSET_2D", + "BATTLE_ANIM_OAMSET_2E", + "BATTLE_ANIM_OAMSET_2F", + "BATTLE_ANIM_OAMSET_30", + "BATTLE_ANIM_OAMSET_31", + "BATTLE_ANIM_OAMSET_32", + "BATTLE_ANIM_OAMSET_33", + "BATTLE_ANIM_OAMSET_34", + "BATTLE_ANIM_OAMSET_35", + "BATTLE_ANIM_OAMSET_36", + "BATTLE_ANIM_OAMSET_37", + "BATTLE_ANIM_OAMSET_38", + "BATTLE_ANIM_OAMSET_39", + "BATTLE_ANIM_OAMSET_3A", + "BATTLE_ANIM_OAMSET_3B", + "BATTLE_ANIM_OAMSET_3C", + "BATTLE_ANIM_OAMSET_3D", + "BATTLE_ANIM_OAMSET_3E", + "BATTLE_ANIM_OAMSET_3F", + "BATTLE_ANIM_OAMSET_40", + "BATTLE_ANIM_OAMSET_41", + "BATTLE_ANIM_OAMSET_42", + "BATTLE_ANIM_OAMSET_43", + "BATTLE_ANIM_OAMSET_44", + "BATTLE_ANIM_OAMSET_45", + "BATTLE_ANIM_OAMSET_46", + "BATTLE_ANIM_OAMSET_47", + "BATTLE_ANIM_OAMSET_48", + "BATTLE_ANIM_OAMSET_49", + "BATTLE_ANIM_OAMSET_4A", + "BATTLE_ANIM_OAMSET_4B", + "BATTLE_ANIM_OAMSET_4C", + "BATTLE_ANIM_OAMSET_4D", + "BATTLE_ANIM_OAMSET_4E", + "BATTLE_ANIM_OAMSET_4F", + "BATTLE_ANIM_OAMSET_50", + "BATTLE_ANIM_OAMSET_51", + "BATTLE_ANIM_OAMSET_52", + "BATTLE_ANIM_OAMSET_53", + "BATTLE_ANIM_OAMSET_54", + "BATTLE_ANIM_OAMSET_55", + "BATTLE_ANIM_OAMSET_56", + "BATTLE_ANIM_OAMSET_57", + "BATTLE_ANIM_OAMSET_58", + "BATTLE_ANIM_OAMSET_59", + "BATTLE_ANIM_OAMSET_5A", + "BATTLE_ANIM_OAMSET_5B", + "BATTLE_ANIM_OAMSET_5C", + "BATTLE_ANIM_OAMSET_5D", + "BATTLE_ANIM_OAMSET_5E", + "BATTLE_ANIM_OAMSET_5F", + "BATTLE_ANIM_OAMSET_60", + "BATTLE_ANIM_OAMSET_61", + "BATTLE_ANIM_OAMSET_62", + "BATTLE_ANIM_OAMSET_63", + "BATTLE_ANIM_OAMSET_64", + "BATTLE_ANIM_OAMSET_65", + "BATTLE_ANIM_OAMSET_66", + "BATTLE_ANIM_OAMSET_67", + "BATTLE_ANIM_OAMSET_68", + "BATTLE_ANIM_OAMSET_69", + "BATTLE_ANIM_OAMSET_6A", + "BATTLE_ANIM_OAMSET_6B", + "BATTLE_ANIM_OAMSET_6C", + "BATTLE_ANIM_OAMSET_6D", + "BATTLE_ANIM_OAMSET_6E", + "BATTLE_ANIM_OAMSET_6F", + "BATTLE_ANIM_OAMSET_70", + "BATTLE_ANIM_OAMSET_71", + "BATTLE_ANIM_OAMSET_72", + "BATTLE_ANIM_OAMSET_73", + "BATTLE_ANIM_OAMSET_74", + "BATTLE_ANIM_OAMSET_75", + "BATTLE_ANIM_OAMSET_76", + "BATTLE_ANIM_OAMSET_77", + "BATTLE_ANIM_OAMSET_78", + "BATTLE_ANIM_OAMSET_79", + "BATTLE_ANIM_OAMSET_7A", + "BATTLE_ANIM_OAMSET_7B", + "BATTLE_ANIM_OAMSET_7C", + "BATTLE_ANIM_OAMSET_7D", + "BATTLE_ANIM_OAMSET_7E", + "BATTLE_ANIM_OAMSET_7F", + "BATTLE_ANIM_OAMSET_80", + "BATTLE_ANIM_OAMSET_81", + "BATTLE_ANIM_OAMSET_82", + "BATTLE_ANIM_OAMSET_83", + "BATTLE_ANIM_OAMSET_84", + "BATTLE_ANIM_OAMSET_85", + "BATTLE_ANIM_OAMSET_86", + "BATTLE_ANIM_OAMSET_87", + "BATTLE_ANIM_OAMSET_88", + "BATTLE_ANIM_OAMSET_89", + "BATTLE_ANIM_OAMSET_8A", + "BATTLE_ANIM_OAMSET_8B", + "BATTLE_ANIM_OAMSET_8C", + "BATTLE_ANIM_OAMSET_8D", + "BATTLE_ANIM_OAMSET_8E", + "BATTLE_ANIM_OAMSET_8F", + "BATTLE_ANIM_OAMSET_90", + "BATTLE_ANIM_OAMSET_91", + "BATTLE_ANIM_OAMSET_92", + "BATTLE_ANIM_OAMSET_93", + "BATTLE_ANIM_OAMSET_94", + "BATTLE_ANIM_OAMSET_95", + "BATTLE_ANIM_OAMSET_96", + "BATTLE_ANIM_OAMSET_97", + "BATTLE_ANIM_OAMSET_98", + "BATTLE_ANIM_OAMSET_99", + "BATTLE_ANIM_OAMSET_9A", + "BATTLE_ANIM_OAMSET_9B", + "BATTLE_ANIM_OAMSET_9C", + "BATTLE_ANIM_OAMSET_9D", + "BATTLE_ANIM_OAMSET_9E", + "BATTLE_ANIM_OAMSET_9F", + "BATTLE_ANIM_OAMSET_A0", + "BATTLE_ANIM_OAMSET_A1", + "BATTLE_ANIM_OAMSET_A2", + "BATTLE_ANIM_OAMSET_A3", + "BATTLE_ANIM_OAMSET_A4", + "BATTLE_ANIM_OAMSET_A5", + "BATTLE_ANIM_OAMSET_A6", + "BATTLE_ANIM_OAMSET_A7", + "BATTLE_ANIM_OAMSET_A8", + "BATTLE_ANIM_OAMSET_A9", + "BATTLE_ANIM_OAMSET_AA", + "BATTLE_ANIM_OAMSET_AB", + "BATTLE_ANIM_OAMSET_AC", + "BATTLE_ANIM_OAMSET_AD", + "BATTLE_ANIM_OAMSET_AE", + "BATTLE_ANIM_OAMSET_AF", + "BATTLE_ANIM_OAMSET_B0", + "BATTLE_ANIM_OAMSET_B1", + "BATTLE_ANIM_OAMSET_B2", + "BATTLE_ANIM_OAMSET_B3", + "BATTLE_ANIM_OAMSET_B4", + "BATTLE_ANIM_OAMSET_B5", + "BATTLE_ANIM_OAMSET_B6", + "BATTLE_ANIM_OAMSET_B7", + "BATTLE_ANIM_OAMSET_B8", + "BATTLE_ANIM_OAMSET_B9", + "BATTLE_ANIM_OAMSET_BA", + "BATTLE_ANIM_OAMSET_BB", + "BATTLE_ANIM_OAMSET_BC", + "BATTLE_ANIM_OAMSET_BD", + "BATTLE_ANIM_OAMSET_BE", + "BATTLE_ANIM_OAMSET_BF", + "BATTLE_ANIM_OAMSET_C0", + "BATTLE_ANIM_OAMSET_C1", + "BATTLE_ANIM_OAMSET_C2", + "BATTLE_ANIM_OAMSET_C3", + "BATTLE_ANIM_OAMSET_C4", + "BATTLE_ANIM_OAMSET_C5", + "BATTLE_ANIM_OAMSET_C6", + "BATTLE_ANIM_OAMSET_C7", + "BATTLE_ANIM_OAMSET_C8", + "BATTLE_ANIM_OAMSET_C9", + "BATTLE_ANIM_OAMSET_CA", + "BATTLE_ANIM_OAMSET_CB", + "BATTLE_ANIM_OAMSET_CC", + "BATTLE_ANIM_OAMSET_CD", + "BATTLE_ANIM_OAMSET_CE", + "BATTLE_ANIM_OAMSET_CF", + "BATTLE_ANIM_OAMSET_D0", + "BATTLE_ANIM_OAMSET_D1", + "BATTLE_ANIM_OAMSET_D2", + "BATTLE_ANIM_OAMSET_D3", + "BATTLE_ANIM_OAMSET_D4", + "BATTLE_ANIM_OAMSET_D5", + "BATTLE_ANIM_OAMSET_D6", + "BATTLE_ANIM_OAMSET_D7" + ], + "battleAnimObPaletteOrder": [ + "PAL_BATTLE_OB_ENEMY", + "PAL_BATTLE_OB_PLAYER", + "PAL_BATTLE_OB_GRAY", + "PAL_BATTLE_OB_YELLOW", + "PAL_BATTLE_OB_RED", + "PAL_BATTLE_OB_GREEN", + "PAL_BATTLE_OB_BLUE", + "PAL_BATTLE_OB_BROWN" + ], + "battleAnimObjectOrder": [ + "BATTLE_ANIM_OBJ_HIT_BIG_YFIX", + "BATTLE_ANIM_OBJ_HIT_YFIX", + "BATTLE_ANIM_OBJ_HIT_SMALL_YFIX", + "BATTLE_ANIM_OBJ_HIT_BIG", + "BATTLE_ANIM_OBJ_HIT", + "BATTLE_ANIM_OBJ_HIT_SMALL", + "BATTLE_ANIM_OBJ_PUNCH", + "BATTLE_ANIM_OBJ_KICK", + "BATTLE_ANIM_OBJ_PALM", + "BATTLE_ANIM_OBJ_FANG", + "BATTLE_ANIM_OBJ_PUNCH_SHAKE", + "BATTLE_ANIM_OBJ_EMBER", + "BATTLE_ANIM_OBJ_DRAGON_RAGE", + "BATTLE_ANIM_OBJ_FLAMETHROWER", + "BATTLE_ANIM_OBJ_FIRE_SPIN", + "BATTLE_ANIM_OBJ_FIRE_BLAST", + "BATTLE_ANIM_OBJ_BURNED", + "BATTLE_ANIM_OBJ_BLIZZARD", + "BATTLE_ANIM_OBJ_ICE", + "BATTLE_ANIM_OBJ_ICE_BEAM", + "BATTLE_ANIM_OBJ_RAZOR_LEAF", + "BATTLE_ANIM_OBJ_POKE_BALL", + "BATTLE_ANIM_OBJ_POKE_BALL_BLOCKED", + "BATTLE_ANIM_OBJ_EXPLOSION1", + "BATTLE_ANIM_OBJ_EXPLOSION2", + "BATTLE_ANIM_OBJ_ACID", + "BATTLE_ANIM_OBJ_SLUDGE", + "BATTLE_ANIM_OBJ_BETA_BALL_POOF", + "BATTLE_ANIM_OBJ_BALL_POOF", + "BATTLE_ANIM_OBJ_BIG_ROCK", + "BATTLE_ANIM_OBJ_SMALL_ROCK", + "BATTLE_ANIM_OBJ_STRENGTH", + "BATTLE_ANIM_OBJ_SEISMIC_TOSS", + "BATTLE_ANIM_OBJ_BUBBLE", + "BATTLE_ANIM_OBJ_SURF", + "BATTLE_ANIM_OBJ_SING", + "BATTLE_ANIM_OBJ_WATER_GUN", + "BATTLE_ANIM_OBJ_HYDRO_PUMP", + "BATTLE_ANIM_OBJ_POWDER", + "BATTLE_ANIM_OBJ_BEAM", + "BATTLE_ANIM_OBJ_BEAM_TIP", + "BATTLE_ANIM_OBJ_ICE_BUILDUP", + "BATTLE_ANIM_OBJ_FROZEN", + "BATTLE_ANIM_OBJ_MASTER_BALL_SPARKLE", + "BATTLE_ANIM_OBJ_RECOVER", + "BATTLE_ANIM_OBJ_THUNDER_CENTER", + "BATTLE_ANIM_OBJ_THUNDER_LEFT", + "BATTLE_ANIM_OBJ_THUNDER_RIGHT", + "BATTLE_ANIM_OBJ_THUNDER_WAVE", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE_BIG", + "BATTLE_ANIM_OBJ_THUNDERBOLT_BALL", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE", + "BATTLE_ANIM_OBJ_THUNDERSHOCK_BALL", + "BATTLE_ANIM_OBJ_CLAMP", + "BATTLE_ANIM_OBJ_BITE", + "BATTLE_ANIM_OBJ_CUT_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_CUT_UP_RIGHT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_SOLAR_BEAM_CHARGE", + "BATTLE_ANIM_OBJ_ABSORB_CENTER", + "BATTLE_ANIM_OBJ_GUST", + "BATTLE_ANIM_OBJ_VINE_WHIP1", + "BATTLE_ANIM_OBJ_VINE_WHIP2", + "BATTLE_ANIM_OBJ_RAZOR_WIND1", + "BATTLE_ANIM_OBJ_RAZOR_WIND2", + "BATTLE_ANIM_OBJ_SONICBOOM_JP", + "BATTLE_ANIM_OBJ_WARP", + "BATTLE_ANIM_OBJ_ABSORB", + "BATTLE_ANIM_OBJ_EGG", + "BATTLE_ANIM_OBJ_FOCUS", + "BATTLE_ANIM_OBJ_BIND1", + "BATTLE_ANIM_OBJ_BIND2", + "BATTLE_ANIM_OBJ_LEECH_SEED", + "BATTLE_ANIM_OBJ_SOUND", + "BATTLE_ANIM_OBJ_WAVE", + "BATTLE_ANIM_OBJ_CONFUSE_RAY", + "BATTLE_ANIM_OBJ_LEER", + "BATTLE_ANIM_OBJ_LEER_TIP", + "BATTLE_ANIM_OBJ_SCREEN", + "BATTLE_ANIM_OBJ_HARDEN", + "BATTLE_ANIM_OBJ_CHICK", + "BATTLE_ANIM_OBJ_AMNESIA", + "BATTLE_ANIM_OBJ_ASLEEP", + "BATTLE_ANIM_OBJ_SKULL", + "BATTLE_ANIM_OBJ_DIG_SAND", + "BATTLE_ANIM_OBJ_DIG_PILE", + "BATTLE_ANIM_OBJ_SAND", + "BATTLE_ANIM_OBJ_PARALYZED", + "BATTLE_ANIM_OBJ_STRING_SHOT", + "BATTLE_ANIM_OBJ_HAZE", + "BATTLE_ANIM_OBJ_MIST", + "BATTLE_ANIM_OBJ_SMOG", + "BATTLE_ANIM_OBJ_POISON_GAS", + "BATTLE_ANIM_OBJ_HORN", + "BATTLE_ANIM_OBJ_NEEDLE", + "BATTLE_ANIM_OBJ_PETAL_DANCE", + "BATTLE_ANIM_OBJ_SLUDGE_BOMB", + "BATTLE_ANIM_OBJ_PAY_DAY", + "BATTLE_ANIM_OBJ_SONICBOOM_JP_UNUSED", + "BATTLE_ANIM_OBJ_MIMIC", + "BATTLE_ANIM_OBJ_ATTRACT", + "BATTLE_ANIM_OBJ_BONEMERANG", + "BATTLE_ANIM_OBJ_BONE_CLUB", + "BATTLE_ANIM_OBJ_BONE_RUSH", + "BATTLE_ANIM_OBJ_SWIFT", + "BATTLE_ANIM_OBJ_KINESIS", + "BATTLE_ANIM_OBJ_FLASH", + "BATTLE_ANIM_OBJ_SHINY", + "BATTLE_ANIM_OBJ_SKY_ATTACK", + "BATTLE_ANIM_OBJ_LICK", + "BATTLE_ANIM_OBJ_WITHDRAW", + "BATTLE_ANIM_OBJ_DRAIN", + "BATTLE_ANIM_OBJ_GROWTH", + "BATTLE_ANIM_OBJ_CONVERSION2", + "BATTLE_ANIM_OBJ_SMOKE", + "BATTLE_ANIM_OBJ_SMOKESCREEN", + "BATTLE_ANIM_OBJ_SWORDS_DANCE", + "BATTLE_ANIM_OBJ_SPEED_LINE", + "BATTLE_ANIM_OBJ_SHARPEN", + "BATTLE_ANIM_OBJ_DEFENSE_CURL", + "BATTLE_ANIM_OBJ_METRONOME_HAND", + "BATTLE_ANIM_OBJ_METRONOME_SPARKLE", + "BATTLE_ANIM_OBJ_DISABLE", + "BATTLE_ANIM_OBJ_AGILITY", + "BATTLE_ANIM_OBJ_HEART", + "BATTLE_ANIM_OBJ_FLAME_WHEEL", + "BATTLE_ANIM_OBJ_SACRED_FIRE", + "BATTLE_ANIM_OBJ_COTTON_SPORE", + "BATTLE_ANIM_OBJ_MILK_DRINK", + "BATTLE_ANIM_OBJ_ANGER", + "BATTLE_ANIM_OBJ_HEAL_BELL", + "BATTLE_ANIM_OBJ_HEAL_BELL_NOTE", + "BATTLE_ANIM_OBJ_BATON_PASS", + "BATTLE_ANIM_OBJ_LOCK_ON", + "BATTLE_ANIM_OBJ_MIND_READER", + "BATTLE_ANIM_OBJ_SAFEGUARD", + "BATTLE_ANIM_OBJ_PROTECT", + "BATTLE_ANIM_OBJ_THIEF", + "BATTLE_ANIM_OBJ_OCTAZOOKA", + "BATTLE_ANIM_OBJ_PRESENT", + "BATTLE_ANIM_OBJ_SPIKES", + "BATTLE_ANIM_OBJ_POWDER_SNOW", + "BATTLE_ANIM_OBJ_DRAGONBREATH", + "BATTLE_ANIM_OBJ_CONVERSION", + "BATTLE_ANIM_OBJ_SPIDER_WEB", + "BATTLE_ANIM_OBJ_CAKE_UNUSED", + "BATTLE_ANIM_OBJ_NIGHTMARE", + "BATTLE_ANIM_OBJ_IN_NIGHTMARE", + "BATTLE_ANIM_OBJ_LOVELY_KISS", + "BATTLE_ANIM_OBJ_SWEET_KISS", + "BATTLE_ANIM_OBJ_SKETCH", + "BATTLE_ANIM_OBJ_ENCORE_HAND", + "BATTLE_ANIM_OBJ_ENCORE_STAR", + "BATTLE_ANIM_OBJ_DESTINY_BOND", + "BATTLE_ANIM_OBJ_MORNING_SUN", + "BATTLE_ANIM_OBJ_GLIMMER", + "BATTLE_ANIM_OBJ_MOONLIGHT", + "BATTLE_ANIM_OBJ_HIDDEN_POWER", + "BATTLE_ANIM_OBJ_CROSS_CHOP1", + "BATTLE_ANIM_OBJ_CROSS_CHOP2", + "BATTLE_ANIM_OBJ_SANDSTORM", + "BATTLE_ANIM_OBJ_ZAP_CANNON", + "BATTLE_ANIM_OBJ_SPITE", + "BATTLE_ANIM_OBJ_CURSE", + "BATTLE_ANIM_OBJ_PERISH_SONG", + "BATTLE_ANIM_OBJ_FORESIGHT", + "BATTLE_ANIM_OBJ_RAPID_SPIN", + "BATTLE_ANIM_OBJ_SWAGGER", + "BATTLE_ANIM_OBJ_BELLY_DRUM_HAND", + "BATTLE_ANIM_OBJ_BELLY_DRUM_NOTE", + "BATTLE_ANIM_OBJ_MEAN_LOOK", + "BATTLE_ANIM_OBJ_BETA_PURSUIT", + "BATTLE_ANIM_OBJ_SHOOTING_SPARKLE", + "BATTLE_ANIM_OBJ_RAIN", + "BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_OBJ_PSYCH_UP", + "BATTLE_ANIM_OBJ_ANCIENTPOWER", + "BATTLE_ANIM_OBJ_AEROBLAST", + "BATTLE_ANIM_OBJ_SHADOW_BALL", + "BATTLE_ANIM_OBJ_ROCK_SMASH", + "BATTLE_ANIM_OBJ_FLOWER", + "BATTLE_ANIM_OBJ_COTTON", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW" + ], + "battleBgEffectOrder": [ + "BATTLE_BG_EFFECT_END", + "BATTLE_BG_EFFECT_FLASH_INVERTED", + "BATTLE_BG_EFFECT_FLASH_WHITE", + "BATTLE_BG_EFFECT_WHITE_HUES", + "BATTLE_BG_EFFECT_BLACK_HUES", + "BATTLE_BG_EFFECT_ALTERNATE_HUES", + "BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED", + "BATTLE_BG_EFFECT_HIDE_MON", + "BATTLE_BG_EFFECT_SHOW_MON", + "BATTLE_BG_EFFECT_ENTER_MON", + "BATTLE_BG_EFFECT_RETURN_MON", + "BATTLE_BG_EFFECT_SURF", + "BATTLE_BG_EFFECT_WHIRLPOOL", + "BATTLE_BG_EFFECT_TELEPORT", + "BATTLE_BG_EFFECT_NIGHT_SHADE", + "BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", + "BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", + "BATTLE_BG_EFFECT_DOUBLE_TEAM", + "BATTLE_BG_EFFECT_ACID_ARMOR", + "BATTLE_BG_EFFECT_RAPID_FLASH", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING", + "BATTLE_BG_EFFECT_FLASH_MON_REPEATING", + "BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK", + "BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_X", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_Y", + "BATTLE_BG_EFFECT_WITHDRAW", + "BATTLE_BG_EFFECT_BOUNCE_DOWN", + "BATTLE_BG_EFFECT_DIG", + "BATTLE_BG_EFFECT_TACKLE", + "BATTLE_BG_EFFECT_BODY_SLAM", + "BATTLE_BG_EFFECT_WOBBLE_MON", + "BATTLE_BG_EFFECT_REMOVE_MON", + "BATTLE_BG_EFFECT_WAVE_DEFORM_MON", + "BATTLE_BG_EFFECT_PSYCHIC", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", + "BATTLE_BG_EFFECT_FLAIL", + "BATTLE_BG_EFFECT_BETA_PURSUIT", + "BATTLE_BG_EFFECT_ROLLOUT", + "BATTLE_BG_EFFECT_VITAL_THROW", + "BATTLE_BG_EFFECT_START_WATER", + "BATTLE_BG_EFFECT_WATER", + "BATTLE_BG_EFFECT_END_WATER", + "BATTLE_BG_EFFECT_VIBRATE_MON", + "BATTLE_BG_EFFECT_WOBBLE_PLAYER", + "BATTLE_BG_EFFECT_WOBBLE_SCREEN" + ], + "cmdQueueOrder": [ + "CMDQUEUE_NULL", + "CMDQUEUE_TYPE1", + "CMDQUEUE_STONETABLE", + "CMDQUEUE_TYPE3", + "CMDQUEUE_TYPE4" + ], + "decoDescOrder": [ + "DECODESC_POSTER", + "DECODESC_LEFT_DOLL", + "DECODESC_RIGHT_DOLL", + "DECODESC_BIG_DOLL", + "DECODESC_CONSOLE" + ], + "eggGroupOrder": [ + "EGG_MONSTER", + "EGG_WATER_1", + "EGG_BUG", + "EGG_FLYING", + "EGG_GROUND", + "EGG_FAIRY", + "EGG_PLANT", + "EGG_HUMANSHAPE", + "EGG_WATER_3", + "EGG_MINERAL", + "EGG_INDETERMINATE", + "EGG_WATER_2", + "EGG_DITTO", + "EGG_DRAGON", + "EGG_NONE" + ], + "engineFlagOrder": [ + "ENGINE_RADIO_CARD", + "ENGINE_MAP_CARD", + "ENGINE_PHONE_CARD", + "ENGINE_EXPN_CARD", + "ENGINE_POKEGEAR", + "ENGINE_DAY_CARE_MAN_HAS_EGG", + "ENGINE_DAY_CARE_MAN_HAS_MON", + "ENGINE_DAY_CARE_LADY_HAS_MON", + "ENGINE_MOM_SAVING_MONEY", + "ENGINE_MOM_ACTIVE", + "ENGINE_UNUSED_TWO_DAY_TIMER_ON", + "ENGINE_POKEDEX", + "ENGINE_UNOWN_DEX", + "ENGINE_CAUGHT_POKERUS", + "ENGINE_ROCKET_SIGNAL_ON_CH20", + "ENGINE_CREDITS_SKIP", + "ENGINE_MOBILE_SYSTEM", + "ENGINE_BUG_CONTEST_TIMER", + "ENGINE_SAFARI_ZONE", + "ENGINE_ROCKETS_IN_RADIO_TOWER", + "ENGINE_BIKE_SHOP_CALL_ENABLED", + "ENGINE_UNUSED_STATUSFLAGS2_5", + "ENGINE_REACHED_GOLDENROD", + "ENGINE_ROCKETS_IN_MAHOGANY", + "ENGINE_STRENGTH_ACTIVE", + "ENGINE_ALWAYS_ON_BIKE", + "ENGINE_DOWNHILL", + "ENGINE_ZEPHYRBADGE", + "ENGINE_HIVEBADGE", + "ENGINE_PLAINBADGE", + "ENGINE_FOGBADGE", + "ENGINE_MINERALBADGE", + "ENGINE_STORMBADGE", + "ENGINE_GLACIERBADGE", + "ENGINE_RISINGBADGE", + "ENGINE_BOULDERBADGE", + "ENGINE_CASCADEBADGE", + "ENGINE_THUNDERBADGE", + "ENGINE_RAINBOWBADGE", + "ENGINE_SOULBADGE", + "ENGINE_MARSHBADGE", + "ENGINE_VOLCANOBADGE", + "ENGINE_EARTHBADGE", + "ENGINE_UNLOCKED_UNOWNS_A_TO_K", + "ENGINE_UNLOCKED_UNOWNS_L_TO_R", + "ENGINE_UNLOCKED_UNOWNS_S_TO_W", + "ENGINE_UNLOCKED_UNOWNS_X_TO_Z", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_4", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_5", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_6", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_7", + "ENGINE_FLYPOINT_PLAYERS_HOUSE", + "ENGINE_FLYPOINT_DEBUG", + "ENGINE_FLYPOINT_PALLET", + "ENGINE_FLYPOINT_VIRIDIAN", + "ENGINE_FLYPOINT_PEWTER", + "ENGINE_FLYPOINT_CERULEAN", + "ENGINE_FLYPOINT_ROCK_TUNNEL", + "ENGINE_FLYPOINT_VERMILION", + "ENGINE_FLYPOINT_LAVENDER", + "ENGINE_FLYPOINT_SAFFRON", + "ENGINE_FLYPOINT_CELADON", + "ENGINE_FLYPOINT_FUCHSIA", + "ENGINE_FLYPOINT_CINNABAR", + "ENGINE_FLYPOINT_INDIGO_PLATEAU", + "ENGINE_FLYPOINT_NEW_BARK", + "ENGINE_FLYPOINT_CHERRYGROVE", + "ENGINE_FLYPOINT_VIOLET", + "ENGINE_FLYPOINT_AZALEA", + "ENGINE_FLYPOINT_CIANWOOD", + "ENGINE_FLYPOINT_GOLDENROD", + "ENGINE_FLYPOINT_OLIVINE", + "ENGINE_FLYPOINT_ECRUTEAK", + "ENGINE_FLYPOINT_MAHOGANY", + "ENGINE_FLYPOINT_LAKE_OF_RAGE", + "ENGINE_FLYPOINT_BLACKTHORN", + "ENGINE_FLYPOINT_SILVER_CAVE", + "ENGINE_FLYPOINT_UNUSED", + "ENGINE_LUCKY_NUMBER_SHOW", + "ENGINE_UNUSED_STATUSFLAGS2_3", + "ENGINE_KURT_MAKING_BALLS", + "ENGINE_DAILY_BUG_CONTEST", + "ENGINE_QWILFISH_SWARM", + "ENGINE_TIME_CAPSULE", + "ENGINE_ALL_FRUIT_TREES", + "ENGINE_GOT_SHUCKIE_TODAY", + "ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED", + "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY", + "ENGINE_MT_MOON_SQUARE_CLEFAIRY", + "ENGINE_UNION_CAVE_LAPRAS", + "ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT", + "ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN", + "ENGINE_DAISYS_GROOMING", + "ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT", + "ENGINE_DAILY_MOVE_TUTOR", + "ENGINE_BUENAS_PASSWORD", + "ENGINE_BUENAS_PASSWORD_2", + "ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON", + "ENGINE_GAME_TIMER_MOBILE", + "ENGINE_PLAYER_IS_FEMALE", + "ENGINE_FOREST_IS_RESTLESS", + "ENGINE_JACK_READY_FOR_REMATCH", + "ENGINE_HUEY_READY_FOR_REMATCH", + "ENGINE_GAVEN_READY_FOR_REMATCH", + "ENGINE_BETH_READY_FOR_REMATCH", + "ENGINE_JOSE_READY_FOR_REMATCH", + "ENGINE_REENA_READY_FOR_REMATCH", + "ENGINE_JOEY_READY_FOR_REMATCH", + "ENGINE_WADE_READY_FOR_REMATCH", + "ENGINE_RALPH_READY_FOR_REMATCH", + "ENGINE_LIZ_READY_FOR_REMATCH", + "ENGINE_ANTHONY_READY_FOR_REMATCH", + "ENGINE_TODD_READY_FOR_REMATCH", + "ENGINE_GINA_READY_FOR_REMATCH", + "ENGINE_ARNIE_READY_FOR_REMATCH", + "ENGINE_ALAN_READY_FOR_REMATCH", + "ENGINE_DANA_READY_FOR_REMATCH", + "ENGINE_CHAD_READY_FOR_REMATCH", + "ENGINE_TULLY_READY_FOR_REMATCH", + "ENGINE_BRENT_READY_FOR_REMATCH", + "ENGINE_TIFFANY_READY_FOR_REMATCH", + "ENGINE_VANCE_READY_FOR_REMATCH", + "ENGINE_WILTON_READY_FOR_REMATCH", + "ENGINE_PARRY_READY_FOR_REMATCH", + "ENGINE_ERIN_READY_FOR_REMATCH", + "ENGINE_BEVERLY_HAS_NUGGET", + "ENGINE_JOSE_HAS_STAR_PIECE", + "ENGINE_WADE_HAS_ITEM", + "ENGINE_GINA_HAS_LEAF_STONE", + "ENGINE_ALAN_HAS_FIRE_STONE", + "ENGINE_DANA_HAS_THUNDERSTONE", + "ENGINE_DEREK_HAS_NUGGET", + "ENGINE_TULLY_HAS_WATER_STONE", + "ENGINE_TIFFANY_HAS_PINK_BOW", + "ENGINE_WILTON_HAS_ITEM", + "ENGINE_JACK_MONDAY_MORNING", + "ENGINE_HUEY_WEDNESDAY_NIGHT", + "ENGINE_GAVEN_THURSDAY_MORNING", + "ENGINE_BETH_FRIDAY_AFTERNOON", + "ENGINE_JOSE_SATURDAY_NIGHT", + "ENGINE_REENA_SUNDAY_MORNING", + "ENGINE_JOEY_MONDAY_AFTERNOON", + "ENGINE_WADE_TUESDAY_NIGHT", + "ENGINE_RALPH_WEDNESDAY_MORNING", + "ENGINE_LIZ_THURSDAY_AFTERNOON", + "ENGINE_ANTHONY_FRIDAY_NIGHT", + "ENGINE_TODD_SATURDAY_MORNING", + "ENGINE_GINA_SUNDAY_AFTERNOON", + "ENGINE_ARNIE_TUESDAY_MORNING", + "ENGINE_ALAN_WEDNESDAY_AFTERNOON", + "ENGINE_DANA_THURSDAY_NIGHT", + "ENGINE_CHAD_FRIDAY_MORNING", + "ENGINE_TULLY_SUNDAY_NIGHT", + "ENGINE_BRENT_MONDAY_MORNING", + "ENGINE_TIFFANY_TUESDAY_AFTERNOON", + "ENGINE_VANCE_WEDNESDAY_NIGHT", + "ENGINE_WILTON_THURSDAY_MORNING", + "ENGINE_PARRY_FRIDAY_AFTERNOON", + "ENGINE_ERIN_SATURDAY_NIGHT", + "ENGINE_KRIS_IN_CABLE_CLUB", + "ENGINE_DUNSPARCE_SWARM", + "ENGINE_YANMA_SWARM" + ], + "environmentOrder": [ + "TOWN", + "ROUTE", + "INDOOR", + "CAVE", + "ENVIRONMENT_5", + "GATE", + "DUNGEON" + ], + "evolveMethodOrder": [ + "EVOLVE_LEVEL", + "EVOLVE_ITEM", + "EVOLVE_TRADE", + "EVOLVE_HAPPINESS", + "EVOLVE_STAT" + ], + "fishGroupOrder": [ + "FISHGROUP_NONE", + "FISHGROUP_SHORE", + "FISHGROUP_OCEAN", + "FISHGROUP_LAKE", + "FISHGROUP_POND", + "FISHGROUP_DRATINI", + "FISHGROUP_QWILFISH_SWARM", + "FISHGROUP_REMORAID_SWARM", + "FISHGROUP_GYARADOS", + "FISHGROUP_DRATINI_2", + "FISHGROUP_WHIRL_ISLANDS", + "FISHGROUP_QWILFISH", + "FISHGROUP_REMORAID", + "FISHGROUP_QWILFISH_NO_SWARM" + ], + "floorOrder": [ + "FLOOR_B4F", + "FLOOR_B3F", + "FLOOR_B2F", + "FLOOR_B1F", + "FLOOR_1F", + "FLOOR_2F", + "FLOOR_3F", + "FLOOR_4F", + "FLOOR_5F", + "FLOOR_6F", + "FLOOR_7F", + "FLOOR_8F", + "FLOOR_9F", + "FLOOR_10F", + "FLOOR_11F", + "FLOOR_ROOF" + ], + "growthRateOrder": [ + "GROWTH_MEDIUM_FAST", + "GROWTH_SLIGHTLY_FAST", + "GROWTH_SLIGHTLY_SLOW", + "GROWTH_MEDIUM_SLOW", + "GROWTH_FAST", + "GROWTH_SLOW" + ], + "heldEffectOrder": [ + "HELD_NONE", + "HELD_BERRY", + "HELD_2", + "HELD_LEFTOVERS", + "HELD_UNUSED_4", + "HELD_5", + "HELD_RESTORE_PP", + "HELD_UNUSED_7", + "HELD_CLEANSE_TAG", + "HELD_UNUSED_9", + "HELD_HEAL_POISON", + "HELD_HEAL_FREEZE", + "HELD_HEAL_BURN", + "HELD_HEAL_SLEEP", + "HELD_HEAL_PARALYZE", + "HELD_HEAL_STATUS", + "HELD_HEAL_CONFUSION", + "HELD_UNUSED_17", + "HELD_UNUSED_18", + "HELD_UNUSED_19", + "HELD_PREVENT_POISON", + "HELD_PREVENT_BURN", + "HELD_PREVENT_FREEZE", + "HELD_PREVENT_SLEEP", + "HELD_PREVENT_PARALYZE", + "HELD_PREVENT_CONFUSE", + "HELD_UNUSED_26", + "HELD_UNUSED_27", + "HELD_UNUSED_28", + "HELD_UNUSED_29", + "HELD_30", + "HELD_ATTACK_UP", + "HELD_DEFENSE_UP", + "HELD_SPEED_UP", + "HELD_SP_ATTACK_UP", + "HELD_SP_DEFENSE_UP", + "HELD_ACCURACY_UP", + "HELD_EVASION_UP", + "HELD_38", + "HELD_UNUSED_39", + "HELD_UNUSED_40", + "HELD_UNUSED_41", + "HELD_METAL_POWDER", + "HELD_UNUSED_43", + "HELD_UNUSED_44", + "HELD_UNUSED_45", + "HELD_UNUSED_46", + "HELD_UNUSED_47", + "HELD_UNUSED_48", + "HELD_UNUSED_49", + "HELD_NORMAL_BOOST", + "HELD_FIGHTING_BOOST", + "HELD_FLYING_BOOST", + "HELD_POISON_BOOST", + "HELD_GROUND_BOOST", + "HELD_ROCK_BOOST", + "HELD_BUG_BOOST", + "HELD_GHOST_BOOST", + "HELD_FIRE_BOOST", + "HELD_WATER_BOOST", + "HELD_GRASS_BOOST", + "HELD_ELECTRIC_BOOST", + "HELD_PSYCHIC_BOOST", + "HELD_ICE_BOOST", + "HELD_DRAGON_BOOST", + "HELD_DARK_BOOST", + "HELD_STEEL_BOOST", + "HELD_UNUSED_67", + "HELD_UNUSED_68", + "HELD_UNUSED_69", + "HELD_CATCH_CHANCE", + "HELD_71", + "HELD_ESCAPE", + "HELD_CRITICAL_UP", + "HELD_QUICK_CLAW", + "HELD_FLINCH", + "HELD_AMULET_COIN", + "HELD_BRIGHTPOWDER", + "HELD_UNUSED_78", + "HELD_FOCUS_BAND" + ], + "iconOrder": [ + "ICON_NULL", + "ICON_POLIWAG", + "ICON_JIGGLYPUFF", + "ICON_DIGLETT", + "ICON_PIKACHU", + "ICON_STARYU", + "ICON_FISH", + "ICON_BIRD", + "ICON_MONSTER", + "ICON_CLEFAIRY", + "ICON_ODDISH", + "ICON_BUG", + "ICON_GHOST", + "ICON_LAPRAS", + "ICON_HUMANSHAPE", + "ICON_FOX", + "ICON_EQUINE", + "ICON_SHELL", + "ICON_BLOB", + "ICON_SERPENT", + "ICON_VOLTORB", + "ICON_SQUIRTLE", + "ICON_BULBASAUR", + "ICON_CHARMANDER", + "ICON_CATERPILLAR", + "ICON_UNOWN", + "ICON_GEODUDE", + "ICON_FIGHTER", + "ICON_EGG", + "ICON_JELLYFISH", + "ICON_MOTH", + "ICON_BAT", + "ICON_SNORLAX", + "ICON_HO_OH", + "ICON_LUGIA", + "ICON_GYARADOS", + "ICON_SLOWPOKE", + "ICON_SUDOWOODO", + "ICON_BIGMON" + ], + "itemMenuOrder": [ + "ITEMMENU_NOUSE", + "ITEMMENU_CURRENT", + "ITEMMENU_PARTY", + "ITEMMENU_CLOSE" + ], + "itemNameCount": 190, + "itemOrder": [ + "MASTER_BALL", + "ULTRA_BALL", + "BRIGHTPOWDER", + "GREAT_BALL", + "POKE_BALL", + "TOWN_MAP", + "BICYCLE", + "MOON_STONE", + "ANTIDOTE", + "BURN_HEAL", + "ICE_HEAL", + "AWAKENING", + "PARLYZ_HEAL", + "FULL_RESTORE", + "MAX_POTION", + "HYPER_POTION", + "SUPER_POTION", + "POTION", + "ESCAPE_ROPE", + "REPEL", + "MAX_ELIXER", + "FIRE_STONE", + "THUNDERSTONE", + "WATER_STONE", + "ITEM_19", + "HP_UP", + "PROTEIN", + "IRON", + "CARBOS", + "LUCKY_PUNCH", + "CALCIUM", + "RARE_CANDY", + "X_ACCURACY", + "LEAF_STONE", + "METAL_POWDER", + "NUGGET", + "POKE_DOLL", + "FULL_HEAL", + "REVIVE", + "MAX_REVIVE", + "GUARD_SPEC", + "SUPER_REPEL", + "MAX_REPEL", + "DIRE_HIT", + "ITEM_2D", + "FRESH_WATER", + "SODA_POP", + "LEMONADE", + "X_ATTACK", + "ITEM_32", + "X_DEFEND", + "X_SPEED", + "X_SPECIAL", + "COIN_CASE", + "ITEMFINDER", + "POKE_FLUTE", + "EXP_SHARE", + "OLD_ROD", + "GOOD_ROD", + "SILVER_LEAF", + "SUPER_ROD", + "PP_UP", + "ETHER", + "MAX_ETHER", + "ELIXER", + "RED_SCALE", + "SECRETPOTION", + "S_S_TICKET", + "MYSTERY_EGG", + "CLEAR_BELL", + "SILVER_WING", + "MOOMOO_MILK", + "QUICK_CLAW", + "PSNCUREBERRY", + "GOLD_LEAF", + "SOFT_SAND", + "SHARP_BEAK", + "PRZCUREBERRY", + "BURNT_BERRY", + "ICE_BERRY", + "POISON_BARB", + "KINGS_ROCK", + "BITTER_BERRY", + "MINT_BERRY", + "RED_APRICORN", + "TINYMUSHROOM", + "BIG_MUSHROOM", + "SILVERPOWDER", + "BLU_APRICORN", + "ITEM_5A", + "AMULET_COIN", + "YLW_APRICORN", + "GRN_APRICORN", + "CLEANSE_TAG", + "MYSTIC_WATER", + "TWISTEDSPOON", + "WHT_APRICORN", + "BLACKBELT_I", + "BLK_APRICORN", + "ITEM_64", + "PNK_APRICORN", + "BLACKGLASSES", + "SLOWPOKETAIL", + "PINK_BOW", + "STICK", + "SMOKE_BALL", + "NEVERMELTICE", + "MAGNET", + "MIRACLEBERRY", + "PEARL", + "BIG_PEARL", + "EVERSTONE", + "SPELL_TAG", + "RAGECANDYBAR", + "GS_BALL", + "BLUE_CARD", + "MIRACLE_SEED", + "THICK_CLUB", + "FOCUS_BAND", + "ITEM_78", + "ENERGYPOWDER", + "ENERGY_ROOT", + "HEAL_POWDER", + "REVIVAL_HERB", + "HARD_STONE", + "LUCKY_EGG", + "CARD_KEY", + "MACHINE_PART", + "EGG_TICKET", + "LOST_ITEM", + "STARDUST", + "STAR_PIECE", + "BASEMENT_KEY", + "PASS", + "ITEM_87", + "ITEM_88", + "ITEM_89", + "CHARCOAL", + "BERRY_JUICE", + "SCOPE_LENS", + "ITEM_8D", + "ITEM_8E", + "METAL_COAT", + "DRAGON_FANG", + "ITEM_91", + "LEFTOVERS", + "ITEM_93", + "ITEM_94", + "ITEM_95", + "MYSTERYBERRY", + "DRAGON_SCALE", + "BERSERK_GENE", + "ITEM_99", + "ITEM_9A", + "ITEM_9B", + "SACRED_ASH", + "HEAVY_BALL", + "FLOWER_MAIL", + "LEVEL_BALL", + "LURE_BALL", + "FAST_BALL", + "ITEM_A2", + "LIGHT_BALL", + "FRIEND_BALL", + "MOON_BALL", + "LOVE_BALL", + "NORMAL_BOX", + "GORGEOUS_BOX", + "SUN_STONE", + "POLKADOT_BOW", + "ITEM_AB", + "UP_GRADE", + "BERRY", + "GOLD_BERRY", + "SQUIRTBOTTLE", + "ITEM_B0", + "PARK_BALL", + "RAINBOW_WING", + "ITEM_B3", + "BRICK_PIECE", + "SURF_MAIL", + "LITEBLUEMAIL", + "PORTRAITMAIL", + "LOVELY_MAIL", + "EON_MAIL", + "MORPH_MAIL", + "BLUESKY_MAIL", + "MUSIC_MAIL", + "MIRAGE_MAIL", + "ITEM_BE", + "TM_DYNAMICPUNCH", + "TM_HEADBUTT", + "TM_CURSE", + "TM_ROLLOUT", + "ITEM_C3", + "TM_ROAR", + "TM_TOXIC", + "TM_ZAP_CANNON", + "TM_ROCK_SMASH", + "TM_PSYCH_UP", + "TM_HIDDEN_POWER", + "TM_SUNNY_DAY", + "TM_SWEET_SCENT", + "TM_SNORE", + "TM_BLIZZARD", + "TM_HYPER_BEAM", + "TM_ICY_WIND", + "TM_PROTECT", + "TM_RAIN_DANCE", + "TM_GIGA_DRAIN", + "TM_ENDURE", + "TM_FRUSTRATION", + "TM_SOLARBEAM", + "TM_IRON_TAIL", + "TM_DRAGONBREATH", + "TM_THUNDER", + "TM_EARTHQUAKE", + "TM_RETURN", + "TM_DIG", + "ITEM_DC", + "TM_PSYCHIC_M", + "TM_SHADOW_BALL", + "TM_MUD_SLAP", + "TM_DOUBLE_TEAM", + "TM_ICE_PUNCH", + "TM_SWAGGER", + "TM_SLEEP_TALK", + "TM_SLUDGE_BOMB", + "TM_SANDSTORM", + "TM_FIRE_BLAST", + "TM_SWIFT", + "TM_DEFENSE_CURL", + "TM_THUNDERPUNCH", + "TM_DREAM_EATER", + "TM_DETECT", + "TM_REST", + "TM_ATTRACT", + "TM_THIEF", + "TM_STEEL_WING", + "TM_FIRE_PUNCH", + "TM_FURY_CUTTER", + "TM_NIGHTMARE", + "HM_CUT", + "HM_FLY", + "HM_SURF", + "HM_STRENGTH", + "HM_FLASH", + "HM_WHIRLPOOL", + "HM_WATERFALL", + "ITEM_FA" + ], + "landmarkOrder": [ + "LANDMARK_SPECIAL", + "LANDMARK_NEW_BARK_TOWN", + "LANDMARK_ROUTE_29", + "LANDMARK_CHERRYGROVE_CITY", + "LANDMARK_ROUTE_30", + "LANDMARK_ROUTE_31", + "LANDMARK_VIOLET_CITY", + "LANDMARK_SPROUT_TOWER", + "LANDMARK_ROUTE_32", + "LANDMARK_RUINS_OF_ALPH", + "LANDMARK_UNION_CAVE", + "LANDMARK_ROUTE_33", + "LANDMARK_AZALEA_TOWN", + "LANDMARK_SLOWPOKE_WELL", + "LANDMARK_ILEX_FOREST", + "LANDMARK_ROUTE_34", + "LANDMARK_GOLDENROD_CITY", + "LANDMARK_RADIO_TOWER", + "LANDMARK_ROUTE_35", + "LANDMARK_NATIONAL_PARK", + "LANDMARK_ROUTE_36", + "LANDMARK_ROUTE_37", + "LANDMARK_ECRUTEAK_CITY", + "LANDMARK_TIN_TOWER", + "LANDMARK_BURNED_TOWER", + "LANDMARK_ROUTE_38", + "LANDMARK_ROUTE_39", + "LANDMARK_OLIVINE_CITY", + "LANDMARK_LIGHTHOUSE", + "LANDMARK_BATTLE_TOWER", + "LANDMARK_ROUTE_40", + "LANDMARK_WHIRL_ISLANDS", + "LANDMARK_ROUTE_41", + "LANDMARK_CIANWOOD_CITY", + "LANDMARK_ROUTE_42", + "LANDMARK_MT_MORTAR", + "LANDMARK_MAHOGANY_TOWN", + "LANDMARK_ROUTE_43", + "LANDMARK_LAKE_OF_RAGE", + "LANDMARK_ROUTE_44", + "LANDMARK_ICE_PATH", + "LANDMARK_BLACKTHORN_CITY", + "LANDMARK_DRAGONS_DEN", + "LANDMARK_ROUTE_45", + "LANDMARK_DARK_CAVE", + "LANDMARK_ROUTE_46", + "LANDMARK_SILVER_CAVE", + "LANDMARK_PALLET_TOWN", + "LANDMARK_ROUTE_1", + "LANDMARK_VIRIDIAN_CITY", + "LANDMARK_ROUTE_2", + "LANDMARK_PEWTER_CITY", + "LANDMARK_ROUTE_3", + "LANDMARK_MT_MOON", + "LANDMARK_ROUTE_4", + "LANDMARK_CERULEAN_CITY", + "LANDMARK_ROUTE_24", + "LANDMARK_ROUTE_25", + "LANDMARK_ROUTE_5", + "LANDMARK_UNDERGROUND_PATH", + "LANDMARK_ROUTE_6", + "LANDMARK_VERMILION_CITY", + "LANDMARK_DIGLETTS_CAVE", + "LANDMARK_ROUTE_7", + "LANDMARK_ROUTE_8", + "LANDMARK_ROUTE_9", + "LANDMARK_ROCK_TUNNEL", + "LANDMARK_ROUTE_10", + "LANDMARK_POWER_PLANT", + "LANDMARK_LAVENDER_TOWN", + "LANDMARK_LAV_RADIO_TOWER", + "LANDMARK_CELADON_CITY", + "LANDMARK_SAFFRON_CITY", + "LANDMARK_ROUTE_11", + "LANDMARK_ROUTE_12", + "LANDMARK_ROUTE_13", + "LANDMARK_ROUTE_14", + "LANDMARK_ROUTE_15", + "LANDMARK_ROUTE_16", + "LANDMARK_ROUTE_17", + "LANDMARK_ROUTE_18", + "LANDMARK_FUCHSIA_CITY", + "LANDMARK_ROUTE_19", + "LANDMARK_ROUTE_20", + "LANDMARK_SEAFOAM_ISLANDS", + "LANDMARK_CINNABAR_ISLAND", + "LANDMARK_ROUTE_21", + "LANDMARK_ROUTE_22", + "LANDMARK_VICTORY_ROAD", + "LANDMARK_ROUTE_23", + "LANDMARK_INDIGO_PLATEAU", + "LANDMARK_ROUTE_26", + "LANDMARK_ROUTE_27", + "LANDMARK_TOHJO_FALLS", + "LANDMARK_ROUTE_28", + "LANDMARK_FAST_SHIP" + ], + "mapCallbackOrder": [ + "MAPCALLBACK_NONE", + "MAPCALLBACK_TILES", + "MAPCALLBACK_OBJECTS", + "MAPCALLBACK_CMDQUEUE", + "MAPCALLBACK_SPRITES", + "MAPCALLBACK_NEWMAP" + ], + "mapGroups": [ + { + "group": 1, + "height": 4, + "map": 1, + "name": "OLIVINE_POKECENTER_1F", + "width": 5 + }, + { + "group": 1, + "height": 8, + "map": 2, + "name": "OLIVINE_GYM", + "width": 5 + }, + { + "group": 1, + "height": 4, + "map": 3, + "name": "OLIVINE_TIMS_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 4, + "name": "OLIVINE_HOUSE_BETA", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 5, + "name": "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 6, + "name": "OLIVINE_GOOD_ROD_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 7, + "name": "OLIVINE_CAFE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 8, + "name": "OLIVINE_MART", + "width": 6 + }, + { + "group": 1, + "height": 4, + "map": 9, + "name": "ROUTE_38_ECRUTEAK_GATE", + "width": 5 + }, + { + "group": 1, + "height": 4, + "map": 10, + "name": "ROUTE_39_BARN", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 11, + "name": "ROUTE_39_FARMHOUSE", + "width": 4 + }, + { + "group": 1, + "height": 9, + "map": 12, + "name": "ROUTE_38", + "width": 20 + }, + { + "group": 1, + "height": 18, + "map": 13, + "name": "ROUTE_39", + "width": 10 + }, + { + "group": 1, + "height": 18, + "map": 14, + "name": "OLIVINE_CITY", + "width": 20 + }, + { + "group": 2, + "height": 4, + "map": 1, + "name": "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 2, + "height": 9, + "map": 2, + "name": "MAHOGANY_GYM", + "width": 5 + }, + { + "group": 2, + "height": 4, + "map": 3, + "name": "MAHOGANY_POKECENTER_1F", + "width": 5 + }, + { + "group": 2, + "height": 4, + "map": 4, + "name": "ROUTE_42_ECRUTEAK_GATE", + "width": 5 + }, + { + "group": 2, + "height": 9, + "map": 5, + "name": "ROUTE_42", + "width": 30 + }, + { + "group": 2, + "height": 9, + "map": 6, + "name": "ROUTE_44", + "width": 30 + }, + { + "group": 2, + "height": 9, + "map": 7, + "name": "MAHOGANY_TOWN", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 1, + "name": "SPROUT_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 2, + "name": "SPROUT_TOWER_2F", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 3, + "name": "SPROUT_TOWER_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 4, + "name": "TIN_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 5, + "name": "TIN_TOWER_2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 6, + "name": "TIN_TOWER_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 7, + "name": "TIN_TOWER_4F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 8, + "name": "TIN_TOWER_5F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 9, + "name": "TIN_TOWER_6F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 10, + "name": "TIN_TOWER_7F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 11, + "name": "TIN_TOWER_8F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 12, + "name": "TIN_TOWER_9F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 13, + "name": "BURNED_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 14, + "name": "BURNED_TOWER_B1F", + "width": 10 + }, + { + "group": 3, + "height": 27, + "map": 15, + "name": "NATIONAL_PARK", + "width": 20 + }, + { + "group": 3, + "height": 27, + "map": 16, + "name": "NATIONAL_PARK_BUG_CONTEST", + "width": 20 + }, + { + "group": 3, + "height": 4, + "map": 17, + "name": "RADIO_TOWER_1F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 18, + "name": "RADIO_TOWER_2F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 19, + "name": "RADIO_TOWER_3F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 20, + "name": "RADIO_TOWER_4F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 21, + "name": "RADIO_TOWER_5F", + "width": 9 + }, + { + "group": 3, + "height": 18, + "map": 22, + "name": "RUINS_OF_ALPH_OUTSIDE", + "width": 10 + }, + { + "group": 3, + "height": 5, + "map": 23, + "name": "RUINS_OF_ALPH_HO_OH_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 24, + "name": "RUINS_OF_ALPH_KABUTO_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 25, + "name": "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 26, + "name": "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 14, + "map": 27, + "name": "RUINS_OF_ALPH_INNER_CHAMBER", + "width": 10 + }, + { + "group": 3, + "height": 4, + "map": 28, + "name": "RUINS_OF_ALPH_RESEARCH_CENTER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 29, + "name": "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 30, + "name": "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 31, + "name": "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 32, + "name": "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 12, + "map": 33, + "name": "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 7, + "map": 34, + "name": "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 35, + "name": "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 7, + "map": 36, + "name": "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 37, + "name": "UNION_CAVE_1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 38, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 39, + "name": "UNION_CAVE_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 40, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 41, + "name": "SLOWPOKE_WELL_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 42, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 43, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 44, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 45, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 46, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 47, + "name": "OLIVINE_LIGHTHOUSE_6F", + "width": 10 + }, + { + "group": 3, + "height": 4, + "map": 48, + "name": "MAHOGANY_MART_1F", + "width": 4 + }, + { + "group": 3, + "height": 9, + "map": 49, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 50, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 51, + "name": "TEAM_ROCKET_BASE_B3F", + "width": 15 + }, + { + "group": 3, + "height": 27, + "map": 52, + "name": "ILEX_FOREST", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 53, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 54, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 55, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 56, + "name": "GOLDENROD_UNDERGROUND_WAREHOUSE", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 57, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + { + "group": 3, + "height": 27, + "map": 58, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 59, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 60, + "name": "MOUNT_MORTAR_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 61, + "name": "ICE_PATH_1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 62, + "name": "ICE_PATH_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 63, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 64, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 65, + "name": "ICE_PATH_B3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 66, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 67, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 68, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 69, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 70, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 71, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 72, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 73, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 74, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 75, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 76, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 77, + "name": "SILVER_CAVE_ITEM_ROOMS", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 78, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 79, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 80, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 81, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + { + "group": 3, + "height": 5, + "map": 82, + "name": "DRAGON_SHRINE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 83, + "name": "TOHJO_FALLS", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 84, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 85, + "name": "MOUNT_MOON", + "width": 15 + }, + { + "group": 3, + "height": 14, + "map": 86, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + { + "group": 3, + "height": 18, + "map": 87, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 88, + "name": "ROCK_TUNNEL_B1F", + "width": 15 + }, + { + "group": 3, + "height": 4, + "map": 89, + "name": "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 90, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + { + "group": 3, + "height": 36, + "map": 91, + "name": "VICTORY_ROAD", + "width": 10 + }, + { + "group": 4, + "height": 9, + "map": 1, + "name": "ECRUTEAK_TIN_TOWER_ENTRANCE", + "width": 10 + }, + { + "group": 4, + "height": 4, + "map": 2, + "name": "WISE_TRIOS_ROOM", + "width": 4 + }, + { + "group": 4, + "height": 4, + "map": 3, + "name": "ECRUTEAK_POKECENTER_1F", + "width": 5 + }, + { + "group": 4, + "height": 4, + "map": 4, + "name": "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 4, + "height": 7, + "map": 5, + "name": "DANCE_THEATER", + "width": 6 + }, + { + "group": 4, + "height": 4, + "map": 6, + "name": "ECRUTEAK_MART", + "width": 6 + }, + { + "group": 4, + "height": 9, + "map": 7, + "name": "ECRUTEAK_GYM", + "width": 5 + }, + { + "group": 4, + "height": 4, + "map": 8, + "name": "ECRUTEAK_ITEMFINDER_HOUSE", + "width": 4 + }, + { + "group": 4, + "height": 18, + "map": 9, + "name": "ECRUTEAK_CITY", + "width": 20 + }, + { + "group": 5, + "height": 9, + "map": 1, + "name": "BLACKTHORN_GYM_1F", + "width": 5 + }, + { + "group": 5, + "height": 9, + "map": 2, + "name": "BLACKTHORN_GYM_2F", + "width": 5 + }, + { + "group": 5, + "height": 4, + "map": 3, + "name": "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 4, + "map": 4, + "name": "BLACKTHORN_EMYS_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 4, + "map": 5, + "name": "BLACKTHORN_MART", + "width": 6 + }, + { + "group": 5, + "height": 4, + "map": 6, + "name": "BLACKTHORN_POKECENTER_1F", + "width": 5 + }, + { + "group": 5, + "height": 4, + "map": 7, + "name": "MOVE_DELETERS_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 45, + "map": 8, + "name": "ROUTE_45", + "width": 10 + }, + { + "group": 5, + "height": 18, + "map": 9, + "name": "ROUTE_46", + "width": 10 + }, + { + "group": 5, + "height": 18, + "map": 10, + "name": "BLACKTHORN_CITY", + "width": 20 + }, + { + "group": 6, + "height": 4, + "map": 1, + "name": "CINNABAR_POKECENTER_1F", + "width": 5 + }, + { + "group": 6, + "height": 4, + "map": 2, + "name": "CINNABAR_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 6, + "height": 4, + "map": 3, + "name": "ROUTE_19_FUCHSIA_GATE", + "width": 5 + }, + { + "group": 6, + "height": 4, + "map": 4, + "name": "SEAFOAM_GYM", + "width": 5 + }, + { + "group": 6, + "height": 18, + "map": 5, + "name": "ROUTE_19", + "width": 10 + }, + { + "group": 6, + "height": 9, + "map": 6, + "name": "ROUTE_20", + "width": 30 + }, + { + "group": 6, + "height": 18, + "map": 7, + "name": "ROUTE_21", + "width": 10 + }, + { + "group": 6, + "height": 9, + "map": 8, + "name": "CINNABAR_ISLAND", + "width": 10 + }, + { + "group": 7, + "height": 4, + "map": 1, + "name": "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 2, + "name": "CERULEAN_POLICE_STATION", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 3, + "name": "CERULEAN_TRADE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 4, + "name": "CERULEAN_POKECENTER_1F", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 5, + "name": "CERULEAN_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 7, + "height": 8, + "map": 6, + "name": "CERULEAN_GYM", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 7, + "name": "CERULEAN_MART", + "width": 6 + }, + { + "group": 7, + "height": 4, + "map": 8, + "name": "ROUTE_10_POKECENTER_1F", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 9, + "name": "ROUTE_10_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 7, + "height": 9, + "map": 10, + "name": "POWER_PLANT", + "width": 10 + }, + { + "group": 7, + "height": 4, + "map": 11, + "name": "BILLS_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 9, + "map": 12, + "name": "ROUTE_4", + "width": 20 + }, + { + "group": 7, + "height": 9, + "map": 13, + "name": "ROUTE_9", + "width": 30 + }, + { + "group": 7, + "height": 9, + "map": 14, + "name": "ROUTE_10_NORTH", + "width": 10 + }, + { + "group": 7, + "height": 9, + "map": 15, + "name": "ROUTE_24", + "width": 10 + }, + { + "group": 7, + "height": 9, + "map": 16, + "name": "ROUTE_25", + "width": 30 + }, + { + "group": 7, + "height": 18, + "map": 17, + "name": "CERULEAN_CITY", + "width": 20 + }, + { + "group": 8, + "height": 4, + "map": 1, + "name": "AZALEA_POKECENTER_1F", + "width": 5 + }, + { + "group": 8, + "height": 4, + "map": 2, + "name": "CHARCOAL_KILN", + "width": 4 + }, + { + "group": 8, + "height": 4, + "map": 3, + "name": "AZALEA_MART", + "width": 6 + }, + { + "group": 8, + "height": 4, + "map": 4, + "name": "KURTS_HOUSE", + "width": 8 + }, + { + "group": 8, + "height": 8, + "map": 5, + "name": "AZALEA_GYM", + "width": 5 + }, + { + "group": 8, + "height": 9, + "map": 6, + "name": "ROUTE_33", + "width": 10 + }, + { + "group": 8, + "height": 9, + "map": 7, + "name": "AZALEA_TOWN", + "width": 20 + }, + { + "group": 9, + "height": 4, + "map": 1, + "name": "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "width": 4 + }, + { + "group": 9, + "height": 4, + "map": 2, + "name": "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "width": 4 + }, + { + "group": 9, + "height": 4, + "map": 3, + "name": "ROUTE_43_MAHOGANY_GATE", + "width": 5 + }, + { + "group": 9, + "height": 4, + "map": 4, + "name": "ROUTE_43_GATE", + "width": 5 + }, + { + "group": 9, + "height": 27, + "map": 5, + "name": "ROUTE_43", + "width": 10 + }, + { + "group": 9, + "height": 18, + "map": 6, + "name": "LAKE_OF_RAGE", + "width": 20 + }, + { + "group": 10, + "height": 45, + "map": 1, + "name": "ROUTE_32", + "width": 10 + }, + { + "group": 10, + "height": 18, + "map": 2, + "name": "ROUTE_35", + "width": 10 + }, + { + "group": 10, + "height": 9, + "map": 3, + "name": "ROUTE_36", + "width": 30 + }, + { + "group": 10, + "height": 9, + "map": 4, + "name": "ROUTE_37", + "width": 10 + }, + { + "group": 10, + "height": 18, + "map": 5, + "name": "VIOLET_CITY", + "width": 20 + }, + { + "group": 10, + "height": 4, + "map": 6, + "name": "VIOLET_MART", + "width": 6 + }, + { + "group": 10, + "height": 8, + "map": 7, + "name": "VIOLET_GYM", + "width": 5 + }, + { + "group": 10, + "height": 8, + "map": 8, + "name": "EARLS_POKEMON_ACADEMY", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 9, + "name": "VIOLET_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 10, + "name": "VIOLET_POKECENTER_1F", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 11, + "name": "VIOLET_KYLES_HOUSE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 12, + "name": "ROUTE_32_RUINS_OF_ALPH_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 13, + "name": "ROUTE_32_POKECENTER_1F", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 14, + "name": "ROUTE_35_GOLDENROD_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 15, + "name": "ROUTE_35_NATIONAL_PARK_GATE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 16, + "name": "ROUTE_36_RUINS_OF_ALPH_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 17, + "name": "ROUTE_36_NATIONAL_PARK_GATE", + "width": 5 + }, + { + "group": 11, + "height": 27, + "map": 1, + "name": "ROUTE_34", + "width": 10 + }, + { + "group": 11, + "height": 18, + "map": 2, + "name": "GOLDENROD_CITY", + "width": 20 + }, + { + "group": 11, + "height": 9, + "map": 3, + "name": "GOLDENROD_GYM", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 4, + "name": "GOLDENROD_BIKE_SHOP", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 5, + "name": "GOLDENROD_HAPPINESS_RATER", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 6, + "name": "BILLS_FAMILYS_HOUSE", + "width": 4 + }, + { + "group": 11, + "height": 9, + "map": 7, + "name": "GOLDENROD_MAGNET_TRAIN_STATION", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 8, + "name": "GOLDENROD_FLOWER_SHOP", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 9, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 11, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 12, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + { + "group": 11, + "height": 2, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + { + "group": 11, + "height": 4, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ROOF", + "width": 8 + }, + { + "group": 11, + "height": 7, + "map": 19, + "name": "GOLDENROD_GAME_CORNER", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 20, + "name": "GOLDENROD_POKECENTER_1F", + "width": 5 + }, + { + "group": 11, + "height": 16, + "map": 21, + "name": "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "width": 16 + }, + { + "group": 11, + "height": 4, + "map": 22, + "name": "ILEX_FOREST_AZALEA_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 23, + "name": "ROUTE_34_ILEX_FOREST_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 24, + "name": "DAY_CARE", + "width": 5 + }, + { + "group": 12, + "height": 9, + "map": 1, + "name": "ROUTE_6", + "width": 10 + }, + { + "group": 12, + "height": 9, + "map": 2, + "name": "ROUTE_11", + "width": 20 + }, + { + "group": 12, + "height": 18, + "map": 3, + "name": "VERMILION_CITY", + "width": 20 + }, + { + "group": 12, + "height": 4, + "map": 4, + "name": "VERMILION_FISHING_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 4, + "map": 5, + "name": "VERMILION_POKECENTER_1F", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 6, + "name": "VERMILION_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 12, + "height": 4, + "map": 7, + "name": "POKEMON_FAN_CLUB", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 8, + "name": "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 4, + "map": 9, + "name": "VERMILION_MART", + "width": 6 + }, + { + "group": 12, + "height": 4, + "map": 10, + "name": "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 9, + "map": 11, + "name": "VERMILION_GYM", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 12, + "name": "ROUTE_6_SAFFRON_GATE", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 13, + "name": "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + { + "group": 13, + "height": 18, + "map": 1, + "name": "ROUTE_1", + "width": 10 + }, + { + "group": 13, + "height": 9, + "map": 2, + "name": "PALLET_TOWN", + "width": 10 + }, + { + "group": 13, + "height": 4, + "map": 3, + "name": "REDS_HOUSE_1F", + "width": 4 + }, + { + "group": 13, + "height": 4, + "map": 4, + "name": "REDS_HOUSE_2F", + "width": 4 + }, + { + "group": 13, + "height": 4, + "map": 5, + "name": "BLUES_HOUSE", + "width": 4 + }, + { + "group": 13, + "height": 6, + "map": 6, + "name": "OAKS_LAB", + "width": 5 + }, + { + "group": 14, + "height": 9, + "map": 1, + "name": "ROUTE_3", + "width": 30 + }, + { + "group": 14, + "height": 18, + "map": 2, + "name": "PEWTER_CITY", + "width": 20 + }, + { + "group": 14, + "height": 4, + "map": 3, + "name": "PEWTER_NIDORAN_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 14, + "height": 7, + "map": 4, + "name": "PEWTER_GYM", + "width": 5 + }, + { + "group": 14, + "height": 4, + "map": 5, + "name": "PEWTER_MART", + "width": 6 + }, + { + "group": 14, + "height": 4, + "map": 6, + "name": "PEWTER_POKECENTER_1F", + "width": 5 + }, + { + "group": 14, + "height": 4, + "map": 7, + "name": "PEWTER_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 14, + "height": 4, + "map": 8, + "name": "PEWTER_SNOOZE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 15, + "height": 18, + "map": 1, + "name": "OLIVINE_PORT", + "width": 10 + }, + { + "group": 15, + "height": 18, + "map": 2, + "name": "VERMILION_PORT", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 3, + "name": "FAST_SHIP_1F", + "width": 16 + }, + { + "group": 15, + "height": 16, + "map": 4, + "name": "FAST_SHIP_CABINS_NNW_NNE_NE", + "width": 4 + }, + { + "group": 15, + "height": 16, + "map": 5, + "name": "FAST_SHIP_CABINS_SW_SSW_NW", + "width": 4 + }, + { + "group": 15, + "height": 17, + "map": 6, + "name": "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "width": 5 + }, + { + "group": 15, + "height": 8, + "map": 7, + "name": "FAST_SHIP_B1F", + "width": 16 + }, + { + "group": 15, + "height": 9, + "map": 8, + "name": "OLIVINE_PORT_PASSAGE", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 9, + "name": "VERMILION_PORT_PASSAGE", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 10, + "name": "MOUNT_MOON_SQUARE", + "width": 15 + }, + { + "group": 15, + "height": 4, + "map": 11, + "name": "MOUNT_MOON_GIFT_SHOP", + "width": 4 + }, + { + "group": 15, + "height": 9, + "map": 12, + "name": "TIN_TOWER_ROOF", + "width": 10 + }, + { + "group": 16, + "height": 9, + "map": 1, + "name": "ROUTE_23", + "width": 10 + }, + { + "group": 16, + "height": 7, + "map": 2, + "name": "INDIGO_PLATEAU_POKECENTER_1F", + "width": 9 + }, + { + "group": 16, + "height": 9, + "map": 3, + "name": "WILLS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 4, + "name": "KOGAS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 5, + "name": "BRUNOS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 6, + "name": "KARENS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 12, + "map": 7, + "name": "LANCES_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 7, + "map": 8, + "name": "HALL_OF_FAME", + "width": 5 + }, + { + "group": 17, + "height": 9, + "map": 1, + "name": "ROUTE_13", + "width": 30 + }, + { + "group": 17, + "height": 18, + "map": 2, + "name": "ROUTE_14", + "width": 10 + }, + { + "group": 17, + "height": 9, + "map": 3, + "name": "ROUTE_15", + "width": 20 + }, + { + "group": 17, + "height": 9, + "map": 4, + "name": "ROUTE_18", + "width": 10 + }, + { + "group": 17, + "height": 18, + "map": 5, + "name": "FUCHSIA_CITY", + "width": 20 + }, + { + "group": 17, + "height": 4, + "map": 6, + "name": "FUCHSIA_MART", + "width": 6 + }, + { + "group": 17, + "height": 4, + "map": 7, + "name": "SAFARI_ZONE_MAIN_OFFICE", + "width": 4 + }, + { + "group": 17, + "height": 9, + "map": 8, + "name": "FUCHSIA_GYM", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 9, + "name": "BILLS_OLDER_SISTERS_HOUSE", + "width": 4 + }, + { + "group": 17, + "height": 4, + "map": 10, + "name": "FUCHSIA_POKECENTER_1F", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 11, + "name": "FUCHSIA_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 17, + "height": 4, + "map": 12, + "name": "SAFARI_ZONE_WARDENS_HOME", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 13, + "name": "ROUTE_15_FUCHSIA_GATE", + "width": 5 + }, + { + "group": 18, + "height": 9, + "map": 1, + "name": "ROUTE_8", + "width": 20 + }, + { + "group": 18, + "height": 27, + "map": 2, + "name": "ROUTE_12", + "width": 10 + }, + { + "group": 18, + "height": 9, + "map": 3, + "name": "ROUTE_10_SOUTH", + "width": 10 + }, + { + "group": 18, + "height": 9, + "map": 4, + "name": "LAVENDER_TOWN", + "width": 10 + }, + { + "group": 18, + "height": 4, + "map": 5, + "name": "LAVENDER_POKECENTER_1F", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 6, + "name": "LAVENDER_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 18, + "height": 4, + "map": 7, + "name": "MR_FUJIS_HOUSE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 8, + "name": "LAVENDER_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 18, + "height": 4, + "map": 9, + "name": "LAVENDER_NAME_RATER", + "width": 4 + }, + { + "group": 18, + "height": 4, + "map": 10, + "name": "LAVENDER_MART", + "width": 6 + }, + { + "group": 18, + "height": 4, + "map": 11, + "name": "SOUL_HOUSE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 12, + "name": "LAV_RADIO_TOWER_1F", + "width": 10 + }, + { + "group": 18, + "height": 4, + "map": 13, + "name": "ROUTE_8_SAFFRON_GATE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 14, + "name": "ROUTE_12_SUPER_ROD_HOUSE", + "width": 4 + }, + { + "group": 19, + "height": 9, + "map": 1, + "name": "ROUTE_28", + "width": 20 + }, + { + "group": 19, + "height": 18, + "map": 2, + "name": "SILVER_CAVE_OUTSIDE", + "width": 20 + }, + { + "group": 19, + "height": 4, + "map": 3, + "name": "SILVER_CAVE_POKECENTER_1F", + "width": 5 + }, + { + "group": 19, + "height": 4, + "map": 4, + "name": "ROUTE_28_STEEL_WING_HOUSE", + "width": 4 + }, + { + "group": 20, + "height": 4, + "map": 1, + "name": "POKECENTER_2F", + "width": 8 + }, + { + "group": 20, + "height": 4, + "map": 2, + "name": "TRADE_CENTER", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 3, + "name": "COLOSSEUM", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 4, + "name": "TIME_CAPSULE", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 5, + "name": "MOBILE_TRADE_ROOM", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 6, + "name": "MOBILE_BATTLE_ROOM", + "width": 5 + }, + { + "group": 21, + "height": 9, + "map": 1, + "name": "ROUTE_7", + "width": 10 + }, + { + "group": 21, + "height": 9, + "map": 2, + "name": "ROUTE_16", + "width": 10 + }, + { + "group": 21, + "height": 45, + "map": 3, + "name": "ROUTE_17", + "width": 10 + }, + { + "group": 21, + "height": 18, + "map": 4, + "name": "CELADON_CITY", + "width": 20 + }, + { + "group": 21, + "height": 4, + "map": 5, + "name": "CELADON_DEPT_STORE_1F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 6, + "name": "CELADON_DEPT_STORE_2F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 7, + "name": "CELADON_DEPT_STORE_3F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 8, + "name": "CELADON_DEPT_STORE_4F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 9, + "name": "CELADON_DEPT_STORE_5F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 10, + "name": "CELADON_DEPT_STORE_6F", + "width": 8 + }, + { + "group": 21, + "height": 2, + "map": 11, + "name": "CELADON_DEPT_STORE_ELEVATOR", + "width": 2 + }, + { + "group": 21, + "height": 5, + "map": 12, + "name": "CELADON_MANSION_1F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 13, + "name": "CELADON_MANSION_2F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 14, + "name": "CELADON_MANSION_3F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 15, + "name": "CELADON_MANSION_ROOF", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 16, + "name": "CELADON_MANSION_ROOF_HOUSE", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 17, + "name": "CELADON_POKECENTER_1F", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 18, + "name": "CELADON_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 21, + "height": 7, + "map": 19, + "name": "CELADON_GAME_CORNER", + "width": 10 + }, + { + "group": 21, + "height": 3, + "map": 20, + "name": "CELADON_GAME_CORNER_PRIZE_ROOM", + "width": 3 + }, + { + "group": 21, + "height": 9, + "map": 21, + "name": "CELADON_GYM", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 22, + "name": "CELADON_CAFE", + "width": 6 + }, + { + "group": 21, + "height": 4, + "map": 23, + "name": "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 24, + "name": "ROUTE_16_GATE", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 25, + "name": "ROUTE_7_SAFFRON_GATE", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 26, + "name": "ROUTE_17_ROUTE_18_GATE", + "width": 5 + }, + { + "group": 22, + "height": 18, + "map": 1, + "name": "ROUTE_40", + "width": 10 + }, + { + "group": 22, + "height": 27, + "map": 2, + "name": "ROUTE_41", + "width": 25 + }, + { + "group": 22, + "height": 27, + "map": 3, + "name": "CIANWOOD_CITY", + "width": 15 + }, + { + "group": 22, + "height": 4, + "map": 4, + "name": "MANIAS_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 9, + "map": 5, + "name": "CIANWOOD_GYM", + "width": 5 + }, + { + "group": 22, + "height": 4, + "map": 6, + "name": "CIANWOOD_POKECENTER_1F", + "width": 5 + }, + { + "group": 22, + "height": 4, + "map": 7, + "name": "CIANWOOD_PHARMACY", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 8, + "name": "CIANWOOD_PHOTO_STUDIO", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 9, + "name": "CIANWOOD_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 10, + "name": "POKE_SEERS_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 5, + "map": 11, + "name": "BATTLE_TOWER_1F", + "width": 8 + }, + { + "group": 22, + "height": 4, + "map": 12, + "name": "BATTLE_TOWER_BATTLE_ROOM", + "width": 4 + }, + { + "group": 22, + "height": 2, + "map": 13, + "name": "BATTLE_TOWER_ELEVATOR", + "width": 2 + }, + { + "group": 22, + "height": 2, + "map": 14, + "name": "BATTLE_TOWER_HALLWAY", + "width": 11 + }, + { + "group": 22, + "height": 4, + "map": 15, + "name": "ROUTE_40_BATTLE_TOWER_GATE", + "width": 5 + }, + { + "group": 22, + "height": 14, + "map": 16, + "name": "BATTLE_TOWER_OUTSIDE", + "width": 10 + }, + { + "group": 23, + "height": 27, + "map": 1, + "name": "ROUTE_2", + "width": 10 + }, + { + "group": 23, + "height": 9, + "map": 2, + "name": "ROUTE_22", + "width": 20 + }, + { + "group": 23, + "height": 18, + "map": 3, + "name": "VIRIDIAN_CITY", + "width": 20 + }, + { + "group": 23, + "height": 9, + "map": 4, + "name": "VIRIDIAN_GYM", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 5, + "name": "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 23, + "height": 7, + "map": 6, + "name": "TRAINER_HOUSE_1F", + "width": 5 + }, + { + "group": 23, + "height": 8, + "map": 7, + "name": "TRAINER_HOUSE_B1F", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 8, + "name": "VIRIDIAN_MART", + "width": 6 + }, + { + "group": 23, + "height": 4, + "map": 9, + "name": "VIRIDIAN_POKECENTER_1F", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 10, + "name": "VIRIDIAN_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 23, + "height": 4, + "map": 11, + "name": "ROUTE_2_NUGGET_HOUSE", + "width": 4 + }, + { + "group": 23, + "height": 4, + "map": 12, + "name": "ROUTE_2_GATE", + "width": 5 + }, + { + "group": 23, + "height": 9, + "map": 13, + "name": "VICTORY_ROAD_GATE", + "width": 10 + }, + { + "group": 24, + "height": 54, + "map": 1, + "name": "ROUTE_26", + "width": 10 + }, + { + "group": 24, + "height": 9, + "map": 2, + "name": "ROUTE_27", + "width": 40 + }, + { + "group": 24, + "height": 9, + "map": 3, + "name": "ROUTE_29", + "width": 30 + }, + { + "group": 24, + "height": 9, + "map": 4, + "name": "NEW_BARK_TOWN", + "width": 10 + }, + { + "group": 24, + "height": 6, + "map": 5, + "name": "ELMS_LAB", + "width": 5 + }, + { + "group": 24, + "height": 4, + "map": 6, + "name": "PLAYERS_HOUSE_1F", + "width": 5 + }, + { + "group": 24, + "height": 3, + "map": 7, + "name": "PLAYERS_HOUSE_2F", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 8, + "name": "PLAYERS_NEIGHBORS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 9, + "name": "ELMS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 10, + "name": "ROUTE_26_HEAL_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 11, + "name": "DAY_OF_WEEK_SIBLINGS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 12, + "name": "ROUTE_27_SANDSTORM_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 13, + "name": "ROUTE_29_ROUTE_46_GATE", + "width": 5 + }, + { + "group": 25, + "height": 9, + "map": 1, + "name": "ROUTE_5", + "width": 10 + }, + { + "group": 25, + "height": 18, + "map": 2, + "name": "SAFFRON_CITY", + "width": 20 + }, + { + "group": 25, + "height": 6, + "map": 3, + "name": "FIGHTING_DOJO", + "width": 5 + }, + { + "group": 25, + "height": 9, + "map": 4, + "name": "SAFFRON_GYM", + "width": 10 + }, + { + "group": 25, + "height": 4, + "map": 5, + "name": "SAFFRON_MART", + "width": 6 + }, + { + "group": 25, + "height": 4, + "map": 6, + "name": "SAFFRON_POKECENTER_1F", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 7, + "name": "SAFFRON_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 25, + "height": 4, + "map": 8, + "name": "MR_PSYCHICS_HOUSE", + "width": 4 + }, + { + "group": 25, + "height": 9, + "map": 9, + "name": "SAFFRON_MAGNET_TRAIN_STATION", + "width": 10 + }, + { + "group": 25, + "height": 4, + "map": 10, + "name": "SILPH_CO_1F", + "width": 8 + }, + { + "group": 25, + "height": 4, + "map": 11, + "name": "COPYCATS_HOUSE_1F", + "width": 4 + }, + { + "group": 25, + "height": 3, + "map": 12, + "name": "COPYCATS_HOUSE_2F", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 13, + "name": "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + { + "group": 25, + "height": 4, + "map": 14, + "name": "ROUTE_5_SAFFRON_GATE", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 15, + "name": "ROUTE_5_CLEANSE_TAG_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 27, + "map": 1, + "name": "ROUTE_30", + "width": 10 + }, + { + "group": 26, + "height": 9, + "map": 2, + "name": "ROUTE_31", + "width": 20 + }, + { + "group": 26, + "height": 9, + "map": 3, + "name": "CHERRYGROVE_CITY", + "width": 20 + }, + { + "group": 26, + "height": 4, + "map": 4, + "name": "CHERRYGROVE_MART", + "width": 6 + }, + { + "group": 26, + "height": 4, + "map": 5, + "name": "CHERRYGROVE_POKECENTER_1F", + "width": 5 + }, + { + "group": 26, + "height": 4, + "map": 6, + "name": "CHERRYGROVE_GYM_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 7, + "name": "GUIDE_GENTS_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 8, + "name": "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 9, + "name": "ROUTE_30_BERRY_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 10, + "name": "MR_POKEMONS_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 11, + "name": "ROUTE_31_VIOLET_GATE", + "width": 5 + } + ], + "mapOrder": [ + "OLIVINE_POKECENTER_1F", + "OLIVINE_GYM", + "OLIVINE_TIMS_HOUSE", + "OLIVINE_HOUSE_BETA", + "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "OLIVINE_GOOD_ROD_HOUSE", + "OLIVINE_CAFE", + "OLIVINE_MART", + "ROUTE_38_ECRUTEAK_GATE", + "ROUTE_39_BARN", + "ROUTE_39_FARMHOUSE", + "ROUTE_38", + "ROUTE_39", + "OLIVINE_CITY", + "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "MAHOGANY_GYM", + "MAHOGANY_POKECENTER_1F", + "ROUTE_42_ECRUTEAK_GATE", + "ROUTE_42", + "ROUTE_44", + "MAHOGANY_TOWN", + "SPROUT_TOWER_1F", + "SPROUT_TOWER_2F", + "SPROUT_TOWER_3F", + "TIN_TOWER_1F", + "TIN_TOWER_2F", + "TIN_TOWER_3F", + "TIN_TOWER_4F", + "TIN_TOWER_5F", + "TIN_TOWER_6F", + "TIN_TOWER_7F", + "TIN_TOWER_8F", + "TIN_TOWER_9F", + "BURNED_TOWER_1F", + "BURNED_TOWER_B1F", + "NATIONAL_PARK", + "NATIONAL_PARK_BUG_CONTEST", + "RADIO_TOWER_1F", + "RADIO_TOWER_2F", + "RADIO_TOWER_3F", + "RADIO_TOWER_4F", + "RADIO_TOWER_5F", + "RUINS_OF_ALPH_OUTSIDE", + "RUINS_OF_ALPH_HO_OH_CHAMBER", + "RUINS_OF_ALPH_KABUTO_CHAMBER", + "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "RUINS_OF_ALPH_INNER_CHAMBER", + "RUINS_OF_ALPH_RESEARCH_CENTER", + "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "UNION_CAVE_1F", + "UNION_CAVE_B1F", + "UNION_CAVE_B2F", + "SLOWPOKE_WELL_B1F", + "SLOWPOKE_WELL_B2F", + "OLIVINE_LIGHTHOUSE_1F", + "OLIVINE_LIGHTHOUSE_2F", + "OLIVINE_LIGHTHOUSE_3F", + "OLIVINE_LIGHTHOUSE_4F", + "OLIVINE_LIGHTHOUSE_5F", + "OLIVINE_LIGHTHOUSE_6F", + "MAHOGANY_MART_1F", + "TEAM_ROCKET_BASE_B1F", + "TEAM_ROCKET_BASE_B2F", + "TEAM_ROCKET_BASE_B3F", + "ILEX_FOREST", + "GOLDENROD_UNDERGROUND", + "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "GOLDENROD_DEPT_STORE_B1F", + "GOLDENROD_UNDERGROUND_WAREHOUSE", + "MOUNT_MORTAR_1F_OUTSIDE", + "MOUNT_MORTAR_1F_INSIDE", + "MOUNT_MORTAR_2F_INSIDE", + "MOUNT_MORTAR_B1F", + "ICE_PATH_1F", + "ICE_PATH_B1F", + "ICE_PATH_B2F_MAHOGANY_SIDE", + "ICE_PATH_B2F_BLACKTHORN_SIDE", + "ICE_PATH_B3F", + "WHIRL_ISLAND_NW", + "WHIRL_ISLAND_NE", + "WHIRL_ISLAND_SW", + "WHIRL_ISLAND_CAVE", + "WHIRL_ISLAND_SE", + "WHIRL_ISLAND_B1F", + "WHIRL_ISLAND_B2F", + "WHIRL_ISLAND_LUGIA_CHAMBER", + "SILVER_CAVE_ROOM_1", + "SILVER_CAVE_ROOM_2", + "SILVER_CAVE_ROOM_3", + "SILVER_CAVE_ITEM_ROOMS", + "DARK_CAVE_VIOLET_ENTRANCE", + "DARK_CAVE_BLACKTHORN_ENTRANCE", + "DRAGONS_DEN_1F", + "DRAGONS_DEN_B1F", + "DRAGON_SHRINE", + "TOHJO_FALLS", + "DIGLETTS_CAVE", + "MOUNT_MOON", + "UNDERGROUND_PATH", + "ROCK_TUNNEL_1F", + "ROCK_TUNNEL_B1F", + "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "SAFARI_ZONE_BETA", + "VICTORY_ROAD", + "ECRUTEAK_TIN_TOWER_ENTRANCE", + "WISE_TRIOS_ROOM", + "ECRUTEAK_POKECENTER_1F", + "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "DANCE_THEATER", + "ECRUTEAK_MART", + "ECRUTEAK_GYM", + "ECRUTEAK_ITEMFINDER_HOUSE", + "ECRUTEAK_CITY", + "BLACKTHORN_GYM_1F", + "BLACKTHORN_GYM_2F", + "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "BLACKTHORN_EMYS_HOUSE", + "BLACKTHORN_MART", + "BLACKTHORN_POKECENTER_1F", + "MOVE_DELETERS_HOUSE", + "ROUTE_45", + "ROUTE_46", + "BLACKTHORN_CITY", + "CINNABAR_POKECENTER_1F", + "CINNABAR_POKECENTER_2F_BETA", + "ROUTE_19_FUCHSIA_GATE", + "SEAFOAM_GYM", + "ROUTE_19", + "ROUTE_20", + "ROUTE_21", + "CINNABAR_ISLAND", + "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "CERULEAN_POLICE_STATION", + "CERULEAN_TRADE_SPEECH_HOUSE", + "CERULEAN_POKECENTER_1F", + "CERULEAN_POKECENTER_2F_BETA", + "CERULEAN_GYM", + "CERULEAN_MART", + "ROUTE_10_POKECENTER_1F", + "ROUTE_10_POKECENTER_2F_BETA", + "POWER_PLANT", + "BILLS_HOUSE", + "ROUTE_4", + "ROUTE_9", + "ROUTE_10_NORTH", + "ROUTE_24", + "ROUTE_25", + "CERULEAN_CITY", + "AZALEA_POKECENTER_1F", + "CHARCOAL_KILN", + "AZALEA_MART", + "KURTS_HOUSE", + "AZALEA_GYM", + "ROUTE_33", + "AZALEA_TOWN", + "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "ROUTE_43_MAHOGANY_GATE", + "ROUTE_43_GATE", + "ROUTE_43", + "LAKE_OF_RAGE", + "ROUTE_32", + "ROUTE_35", + "ROUTE_36", + "ROUTE_37", + "VIOLET_CITY", + "VIOLET_MART", + "VIOLET_GYM", + "EARLS_POKEMON_ACADEMY", + "VIOLET_NICKNAME_SPEECH_HOUSE", + "VIOLET_POKECENTER_1F", + "VIOLET_KYLES_HOUSE", + "ROUTE_32_RUINS_OF_ALPH_GATE", + "ROUTE_32_POKECENTER_1F", + "ROUTE_35_GOLDENROD_GATE", + "ROUTE_35_NATIONAL_PARK_GATE", + "ROUTE_36_RUINS_OF_ALPH_GATE", + "ROUTE_36_NATIONAL_PARK_GATE", + "ROUTE_34", + "GOLDENROD_CITY", + "GOLDENROD_GYM", + "GOLDENROD_BIKE_SHOP", + "GOLDENROD_HAPPINESS_RATER", + "BILLS_FAMILYS_HOUSE", + "GOLDENROD_MAGNET_TRAIN_STATION", + "GOLDENROD_FLOWER_SHOP", + "GOLDENROD_PP_SPEECH_HOUSE", + "GOLDENROD_NAME_RATER", + "GOLDENROD_DEPT_STORE_1F", + "GOLDENROD_DEPT_STORE_2F", + "GOLDENROD_DEPT_STORE_3F", + "GOLDENROD_DEPT_STORE_4F", + "GOLDENROD_DEPT_STORE_5F", + "GOLDENROD_DEPT_STORE_6F", + "GOLDENROD_DEPT_STORE_ELEVATOR", + "GOLDENROD_DEPT_STORE_ROOF", + "GOLDENROD_GAME_CORNER", + "GOLDENROD_POKECENTER_1F", + "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "ILEX_FOREST_AZALEA_GATE", + "ROUTE_34_ILEX_FOREST_GATE", + "DAY_CARE", + "ROUTE_6", + "ROUTE_11", + "VERMILION_CITY", + "VERMILION_FISHING_SPEECH_HOUSE", + "VERMILION_POKECENTER_1F", + "VERMILION_POKECENTER_2F_BETA", + "POKEMON_FAN_CLUB", + "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "VERMILION_MART", + "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "VERMILION_GYM", + "ROUTE_6_SAFFRON_GATE", + "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "ROUTE_1", + "PALLET_TOWN", + "REDS_HOUSE_1F", + "REDS_HOUSE_2F", + "BLUES_HOUSE", + "OAKS_LAB", + "ROUTE_3", + "PEWTER_CITY", + "PEWTER_NIDORAN_SPEECH_HOUSE", + "PEWTER_GYM", + "PEWTER_MART", + "PEWTER_POKECENTER_1F", + "PEWTER_POKECENTER_2F_BETA", + "PEWTER_SNOOZE_SPEECH_HOUSE", + "OLIVINE_PORT", + "VERMILION_PORT", + "FAST_SHIP_1F", + "FAST_SHIP_CABINS_NNW_NNE_NE", + "FAST_SHIP_CABINS_SW_SSW_NW", + "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "FAST_SHIP_B1F", + "OLIVINE_PORT_PASSAGE", + "VERMILION_PORT_PASSAGE", + "MOUNT_MOON_SQUARE", + "MOUNT_MOON_GIFT_SHOP", + "TIN_TOWER_ROOF", + "ROUTE_23", + "INDIGO_PLATEAU_POKECENTER_1F", + "WILLS_ROOM", + "KOGAS_ROOM", + "BRUNOS_ROOM", + "KARENS_ROOM", + "LANCES_ROOM", + "HALL_OF_FAME", + "ROUTE_13", + "ROUTE_14", + "ROUTE_15", + "ROUTE_18", + "FUCHSIA_CITY", + "FUCHSIA_MART", + "SAFARI_ZONE_MAIN_OFFICE", + "FUCHSIA_GYM", + "BILLS_OLDER_SISTERS_HOUSE", + "FUCHSIA_POKECENTER_1F", + "FUCHSIA_POKECENTER_2F_BETA", + "SAFARI_ZONE_WARDENS_HOME", + "ROUTE_15_FUCHSIA_GATE", + "ROUTE_8", + "ROUTE_12", + "ROUTE_10_SOUTH", + "LAVENDER_TOWN", + "LAVENDER_POKECENTER_1F", + "LAVENDER_POKECENTER_2F_BETA", + "MR_FUJIS_HOUSE", + "LAVENDER_SPEECH_HOUSE", + "LAVENDER_NAME_RATER", + "LAVENDER_MART", + "SOUL_HOUSE", + "LAV_RADIO_TOWER_1F", + "ROUTE_8_SAFFRON_GATE", + "ROUTE_12_SUPER_ROD_HOUSE", + "ROUTE_28", + "SILVER_CAVE_OUTSIDE", + "SILVER_CAVE_POKECENTER_1F", + "ROUTE_28_STEEL_WING_HOUSE", + "POKECENTER_2F", + "TRADE_CENTER", + "COLOSSEUM", + "TIME_CAPSULE", + "MOBILE_TRADE_ROOM", + "MOBILE_BATTLE_ROOM", + "ROUTE_7", + "ROUTE_16", + "ROUTE_17", + "CELADON_CITY", + "CELADON_DEPT_STORE_1F", + "CELADON_DEPT_STORE_2F", + "CELADON_DEPT_STORE_3F", + "CELADON_DEPT_STORE_4F", + "CELADON_DEPT_STORE_5F", + "CELADON_DEPT_STORE_6F", + "CELADON_DEPT_STORE_ELEVATOR", + "CELADON_MANSION_1F", + "CELADON_MANSION_2F", + "CELADON_MANSION_3F", + "CELADON_MANSION_ROOF", + "CELADON_MANSION_ROOF_HOUSE", + "CELADON_POKECENTER_1F", + "CELADON_POKECENTER_2F_BETA", + "CELADON_GAME_CORNER", + "CELADON_GAME_CORNER_PRIZE_ROOM", + "CELADON_GYM", + "CELADON_CAFE", + "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "ROUTE_16_GATE", + "ROUTE_7_SAFFRON_GATE", + "ROUTE_17_ROUTE_18_GATE", + "ROUTE_40", + "ROUTE_41", + "CIANWOOD_CITY", + "MANIAS_HOUSE", + "CIANWOOD_GYM", + "CIANWOOD_POKECENTER_1F", + "CIANWOOD_PHARMACY", + "CIANWOOD_PHOTO_STUDIO", + "CIANWOOD_LUGIA_SPEECH_HOUSE", + "POKE_SEERS_HOUSE", + "BATTLE_TOWER_1F", + "BATTLE_TOWER_BATTLE_ROOM", + "BATTLE_TOWER_ELEVATOR", + "BATTLE_TOWER_HALLWAY", + "ROUTE_40_BATTLE_TOWER_GATE", + "BATTLE_TOWER_OUTSIDE", + "ROUTE_2", + "ROUTE_22", + "VIRIDIAN_CITY", + "VIRIDIAN_GYM", + "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "TRAINER_HOUSE_1F", + "TRAINER_HOUSE_B1F", + "VIRIDIAN_MART", + "VIRIDIAN_POKECENTER_1F", + "VIRIDIAN_POKECENTER_2F_BETA", + "ROUTE_2_NUGGET_HOUSE", + "ROUTE_2_GATE", + "VICTORY_ROAD_GATE", + "ROUTE_26", + "ROUTE_27", + "ROUTE_29", + "NEW_BARK_TOWN", + "ELMS_LAB", + "PLAYERS_HOUSE_1F", + "PLAYERS_HOUSE_2F", + "PLAYERS_NEIGHBORS_HOUSE", + "ELMS_HOUSE", + "ROUTE_26_HEAL_HOUSE", + "DAY_OF_WEEK_SIBLINGS_HOUSE", + "ROUTE_27_SANDSTORM_HOUSE", + "ROUTE_29_ROUTE_46_GATE", + "ROUTE_5", + "SAFFRON_CITY", + "FIGHTING_DOJO", + "SAFFRON_GYM", + "SAFFRON_MART", + "SAFFRON_POKECENTER_1F", + "SAFFRON_POKECENTER_2F_BETA", + "MR_PSYCHICS_HOUSE", + "SAFFRON_MAGNET_TRAIN_STATION", + "SILPH_CO_1F", + "COPYCATS_HOUSE_1F", + "COPYCATS_HOUSE_2F", + "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "ROUTE_5_SAFFRON_GATE", + "ROUTE_5_CLEANSE_TAG_HOUSE", + "ROUTE_30", + "ROUTE_31", + "CHERRYGROVE_CITY", + "CHERRYGROVE_MART", + "CHERRYGROVE_POKECENTER_1F", + "CHERRYGROVE_GYM_SPEECH_HOUSE", + "GUIDE_GENTS_HOUSE", + "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "ROUTE_30_BERRY_HOUSE", + "MR_POKEMONS_HOUSE", + "ROUTE_31_VIOLET_GATE" + ], + "moveEffectOrder": [ + "EFFECT_NORMAL_HIT", + "EFFECT_SLEEP", + "EFFECT_POISON_HIT", + "EFFECT_LEECH_HIT", + "EFFECT_BURN_HIT", + "EFFECT_FREEZE_HIT", + "EFFECT_PARALYZE_HIT", + "EFFECT_SELFDESTRUCT", + "EFFECT_DREAM_EATER", + "EFFECT_MIRROR_MOVE", + "EFFECT_ATTACK_UP", + "EFFECT_DEFENSE_UP", + "EFFECT_SPEED_UP", + "EFFECT_SP_ATK_UP", + "EFFECT_SP_DEF_UP", + "EFFECT_ACCURACY_UP", + "EFFECT_EVASION_UP", + "EFFECT_ALWAYS_HIT", + "EFFECT_ATTACK_DOWN", + "EFFECT_DEFENSE_DOWN", + "EFFECT_SPEED_DOWN", + "EFFECT_SP_ATK_DOWN", + "EFFECT_SP_DEF_DOWN", + "EFFECT_ACCURACY_DOWN", + "EFFECT_EVASION_DOWN", + "EFFECT_RESET_STATS", + "EFFECT_BIDE", + "EFFECT_RAMPAGE", + "EFFECT_FORCE_SWITCH", + "EFFECT_MULTI_HIT", + "EFFECT_CONVERSION", + "EFFECT_FLINCH_HIT", + "EFFECT_HEAL", + "EFFECT_TOXIC", + "EFFECT_PAY_DAY", + "EFFECT_LIGHT_SCREEN", + "EFFECT_TRI_ATTACK", + "EFFECT_UNUSED_25", + "EFFECT_OHKO", + "EFFECT_RAZOR_WIND", + "EFFECT_SUPER_FANG", + "EFFECT_STATIC_DAMAGE", + "EFFECT_TRAP_TARGET", + "EFFECT_UNUSED_2B", + "EFFECT_DOUBLE_HIT", + "EFFECT_JUMP_KICK", + "EFFECT_MIST", + "EFFECT_FOCUS_ENERGY", + "EFFECT_RECOIL_HIT", + "EFFECT_CONFUSE", + "EFFECT_ATTACK_UP_2", + "EFFECT_DEFENSE_UP_2", + "EFFECT_SPEED_UP_2", + "EFFECT_SP_ATK_UP_2", + "EFFECT_SP_DEF_UP_2", + "EFFECT_ACCURACY_UP_2", + "EFFECT_EVASION_UP_2", + "EFFECT_TRANSFORM", + "EFFECT_ATTACK_DOWN_2", + "EFFECT_DEFENSE_DOWN_2", + "EFFECT_SPEED_DOWN_2", + "EFFECT_SP_ATK_DOWN_2", + "EFFECT_SP_DEF_DOWN_2", + "EFFECT_ACCURACY_DOWN_2", + "EFFECT_EVASION_DOWN_2", + "EFFECT_REFLECT", + "EFFECT_POISON", + "EFFECT_PARALYZE", + "EFFECT_ATTACK_DOWN_HIT", + "EFFECT_DEFENSE_DOWN_HIT", + "EFFECT_SPEED_DOWN_HIT", + "EFFECT_SP_ATK_DOWN_HIT", + "EFFECT_SP_DEF_DOWN_HIT", + "EFFECT_ACCURACY_DOWN_HIT", + "EFFECT_EVASION_DOWN_HIT", + "EFFECT_SKY_ATTACK", + "EFFECT_CONFUSE_HIT", + "EFFECT_POISON_MULTI_HIT", + "EFFECT_UNUSED_4E", + "EFFECT_SUBSTITUTE", + "EFFECT_HYPER_BEAM", + "EFFECT_RAGE", + "EFFECT_MIMIC", + "EFFECT_METRONOME", + "EFFECT_LEECH_SEED", + "EFFECT_SPLASH", + "EFFECT_DISABLE", + "EFFECT_LEVEL_DAMAGE", + "EFFECT_PSYWAVE", + "EFFECT_COUNTER", + "EFFECT_ENCORE", + "EFFECT_PAIN_SPLIT", + "EFFECT_SNORE", + "EFFECT_CONVERSION2", + "EFFECT_LOCK_ON", + "EFFECT_SKETCH", + "EFFECT_DEFROST_OPPONENT", + "EFFECT_SLEEP_TALK", + "EFFECT_DESTINY_BOND", + "EFFECT_REVERSAL", + "EFFECT_SPITE", + "EFFECT_FALSE_SWIPE", + "EFFECT_HEAL_BELL", + "EFFECT_PRIORITY_HIT", + "EFFECT_TRIPLE_KICK", + "EFFECT_THIEF", + "EFFECT_MEAN_LOOK", + "EFFECT_NIGHTMARE", + "EFFECT_FLAME_WHEEL", + "EFFECT_CURSE", + "EFFECT_UNUSED_6E", + "EFFECT_PROTECT", + "EFFECT_SPIKES", + "EFFECT_FORESIGHT", + "EFFECT_PERISH_SONG", + "EFFECT_SANDSTORM", + "EFFECT_ENDURE", + "EFFECT_ROLLOUT", + "EFFECT_SWAGGER", + "EFFECT_FURY_CUTTER", + "EFFECT_ATTRACT", + "EFFECT_RETURN", + "EFFECT_PRESENT", + "EFFECT_FRUSTRATION", + "EFFECT_SAFEGUARD", + "EFFECT_SACRED_FIRE", + "EFFECT_MAGNITUDE", + "EFFECT_BATON_PASS", + "EFFECT_PURSUIT", + "EFFECT_RAPID_SPIN", + "EFFECT_UNUSED_82", + "EFFECT_UNUSED_83", + "EFFECT_MORNING_SUN", + "EFFECT_SYNTHESIS", + "EFFECT_MOONLIGHT", + "EFFECT_HIDDEN_POWER", + "EFFECT_RAIN_DANCE", + "EFFECT_SUNNY_DAY", + "EFFECT_DEFENSE_UP_HIT", + "EFFECT_ATTACK_UP_HIT", + "EFFECT_ALL_UP_HIT", + "EFFECT_FAKE_OUT", + "EFFECT_BELLY_DRUM", + "EFFECT_PSYCH_UP", + "EFFECT_MIRROR_COAT", + "EFFECT_SKULL_BASH", + "EFFECT_TWISTER", + "EFFECT_EARTHQUAKE", + "EFFECT_FUTURE_SIGHT", + "EFFECT_GUST", + "EFFECT_STOMP", + "EFFECT_SOLARBEAM", + "EFFECT_THUNDER", + "EFFECT_TELEPORT", + "EFFECT_BEAT_UP", + "EFFECT_FLY", + "EFFECT_DEFENSE_CURL" + ], + "moveOrder": [ + "POUND", + "KARATE_CHOP", + "DOUBLESLAP", + "COMET_PUNCH", + "MEGA_PUNCH", + "PAY_DAY", + "FIRE_PUNCH", + "ICE_PUNCH", + "THUNDERPUNCH", + "SCRATCH", + "VICEGRIP", + "GUILLOTINE", + "RAZOR_WIND", + "SWORDS_DANCE", + "CUT", + "GUST", + "WING_ATTACK", + "WHIRLWIND", + "FLY", + "BIND", + "SLAM", + "VINE_WHIP", + "STOMP", + "DOUBLE_KICK", + "MEGA_KICK", + "JUMP_KICK", + "ROLLING_KICK", + "SAND_ATTACK", + "HEADBUTT", + "HORN_ATTACK", + "FURY_ATTACK", + "HORN_DRILL", + "TACKLE", + "BODY_SLAM", + "WRAP", + "TAKE_DOWN", + "THRASH", + "DOUBLE_EDGE", + "TAIL_WHIP", + "POISON_STING", + "TWINEEDLE", + "PIN_MISSILE", + "LEER", + "BITE", + "GROWL", + "ROAR", + "SING", + "SUPERSONIC", + "SONICBOOM", + "DISABLE", + "ACID", + "EMBER", + "FLAMETHROWER", + "MIST", + "WATER_GUN", + "HYDRO_PUMP", + "SURF", + "ICE_BEAM", + "BLIZZARD", + "PSYBEAM", + "BUBBLEBEAM", + "AURORA_BEAM", + "HYPER_BEAM", + "PECK", + "DRILL_PECK", + "SUBMISSION", + "LOW_KICK", + "COUNTER", + "SEISMIC_TOSS", + "STRENGTH", + "ABSORB", + "MEGA_DRAIN", + "LEECH_SEED", + "GROWTH", + "RAZOR_LEAF", + "SOLARBEAM", + "POISONPOWDER", + "STUN_SPORE", + "SLEEP_POWDER", + "PETAL_DANCE", + "STRING_SHOT", + "DRAGON_RAGE", + "FIRE_SPIN", + "THUNDERSHOCK", + "THUNDERBOLT", + "THUNDER_WAVE", + "THUNDER", + "ROCK_THROW", + "EARTHQUAKE", + "FISSURE", + "DIG", + "TOXIC", + "CONFUSION", + "PSYCHIC_M", + "HYPNOSIS", + "MEDITATE", + "AGILITY", + "QUICK_ATTACK", + "RAGE", + "TELEPORT", + "NIGHT_SHADE", + "MIMIC", + "SCREECH", + "DOUBLE_TEAM", + "RECOVER", + "HARDEN", + "MINIMIZE", + "SMOKESCREEN", + "CONFUSE_RAY", + "WITHDRAW", + "DEFENSE_CURL", + "BARRIER", + "LIGHT_SCREEN", + "HAZE", + "REFLECT", + "FOCUS_ENERGY", + "BIDE", + "METRONOME", + "MIRROR_MOVE", + "SELFDESTRUCT", + "EGG_BOMB", + "LICK", + "SMOG", + "SLUDGE", + "BONE_CLUB", + "FIRE_BLAST", + "WATERFALL", + "CLAMP", + "SWIFT", + "SKULL_BASH", + "SPIKE_CANNON", + "CONSTRICT", + "AMNESIA", + "KINESIS", + "SOFTBOILED", + "HI_JUMP_KICK", + "GLARE", + "DREAM_EATER", + "POISON_GAS", + "BARRAGE", + "LEECH_LIFE", + "LOVELY_KISS", + "SKY_ATTACK", + "TRANSFORM", + "BUBBLE", + "DIZZY_PUNCH", + "SPORE", + "FLASH", + "PSYWAVE", + "SPLASH", + "ACID_ARMOR", + "CRABHAMMER", + "EXPLOSION", + "FURY_SWIPES", + "BONEMERANG", + "REST", + "ROCK_SLIDE", + "HYPER_FANG", + "SHARPEN", + "CONVERSION", + "TRI_ATTACK", + "SUPER_FANG", + "SLASH", + "SUBSTITUTE", + "STRUGGLE", + "SKETCH", + "TRIPLE_KICK", + "THIEF", + "SPIDER_WEB", + "MIND_READER", + "NIGHTMARE", + "FLAME_WHEEL", + "SNORE", + "CURSE", + "FLAIL", + "CONVERSION2", + "AEROBLAST", + "COTTON_SPORE", + "REVERSAL", + "SPITE", + "POWDER_SNOW", + "PROTECT", + "MACH_PUNCH", + "SCARY_FACE", + "FAINT_ATTACK", + "SWEET_KISS", + "BELLY_DRUM", + "SLUDGE_BOMB", + "MUD_SLAP", + "OCTAZOOKA", + "SPIKES", + "ZAP_CANNON", + "FORESIGHT", + "DESTINY_BOND", + "PERISH_SONG", + "ICY_WIND", + "DETECT", + "BONE_RUSH", + "LOCK_ON", + "OUTRAGE", + "SANDSTORM", + "GIGA_DRAIN", + "ENDURE", + "CHARM", + "ROLLOUT", + "FALSE_SWIPE", + "SWAGGER", + "MILK_DRINK", + "SPARK", + "FURY_CUTTER", + "STEEL_WING", + "MEAN_LOOK", + "ATTRACT", + "SLEEP_TALK", + "HEAL_BELL", + "RETURN", + "PRESENT", + "FRUSTRATION", + "SAFEGUARD", + "PAIN_SPLIT", + "SACRED_FIRE", + "MAGNITUDE", + "DYNAMICPUNCH", + "MEGAHORN", + "DRAGONBREATH", + "BATON_PASS", + "ENCORE", + "PURSUIT", + "RAPID_SPIN", + "SWEET_SCENT", + "IRON_TAIL", + "METAL_CLAW", + "VITAL_THROW", + "MORNING_SUN", + "SYNTHESIS", + "MOONLIGHT", + "HIDDEN_POWER", + "CROSS_CHOP", + "TWISTER", + "RAIN_DANCE", + "SUNNY_DAY", + "CRUNCH", + "MIRROR_COAT", + "PSYCH_UP", + "EXTREMESPEED", + "ANCIENTPOWER", + "SHADOW_BALL", + "FUTURE_SIGHT", + "ROCK_SMASH", + "WHIRLPOOL", + "BEAT_UP" + ], + "musicOrder": [ + "Music_Nothing", + "Music_TitleScreen", + "Music_Route1", + "Music_Route3", + "Music_Route12", + "Music_MagnetTrain", + "Music_KantoGymBattle", + "Music_KantoTrainerBattle", + "Music_KantoWildBattle", + "Music_PokemonCenter", + "Music_LookHiker", + "Music_LookLass", + "Music_LookOfficer", + "Music_HealPokemon", + "Music_LavenderTown", + "Music_Route2", + "Music_MtMoon", + "Music_ShowMeAround", + "Music_GameCorner", + "Music_Bicycle", + "Music_HallOfFame", + "Music_ViridianCity", + "Music_CeladonCity", + "Music_TrainerVictory", + "Music_WildPokemonVictory", + "Music_GymLeaderVictory", + "Music_MtMoonSquare", + "Music_Gym", + "Music_PalletTown", + "Music_ProfOaksPokemonTalk", + "Music_ProfOak", + "Music_LookRival", + "Music_AfterTheRivalFight", + "Music_Surf", + "Music_Evolution", + "Music_NationalPark", + "Music_Credits", + "Music_AzaleaTown", + "Music_CherrygroveCity", + "Music_LookKimonoGirl", + "Music_UnionCave", + "Music_JohtoWildBattle", + "Music_JohtoTrainerBattle", + "Music_Route30", + "Music_EcruteakCity", + "Music_VioletCity", + "Music_JohtoGymBattle", + "Music_ChampionBattle", + "Music_RivalBattle", + "Music_RocketBattle", + "Music_ElmsLab", + "Music_DarkCave", + "Music_Route29", + "Music_Route36", + "Music_SSAqua", + "Music_LookYoungster", + "Music_LookBeauty", + "Music_LookRocket", + "Music_LookPokemaniac", + "Music_LookSage", + "Music_NewBarkTown", + "Music_GoldenrodCity", + "Music_VermilionCity", + "Music_PokemonChannel", + "Music_PokeFluteChannel", + "Music_TinTower", + "Music_SproutTower", + "Music_BurnedTower", + "Music_Lighthouse", + "Music_LakeOfRage", + "Music_IndigoPlateau", + "Music_Route37", + "Music_RocketHideout", + "Music_DragonsDen", + "Music_JohtoWildBattleNight", + "Music_RuinsOfAlphRadio", + "Music_SuccessfulCapture", + "Music_Route26", + "Music_Mom", + "Music_VictoryRoad", + "Music_PokemonLullaby", + "Music_PokemonMarch", + "Music_GoldSilverOpening", + "Music_GoldSilverOpening2", + "Music_MainMenu", + "Music_RuinsOfAlphInterior", + "Music_RocketTheme", + "Music_DancingHall", + "Music_ContestResults", + "Music_BugCatchingContest", + "Music_LakeOfRageRocketRadio", + "Music_Printer", + "Music_PostCredits", + "Music_Clair", + "Music_MobileAdapterMenu", + "Music_MobileAdapter", + "Music_BuenasPassword", + "Music_LookMysticalMan", + "Music_CrystalOpening", + "Music_BattleTowerTheme", + "Music_SuicuneBattle", + "Music_BattleTowerLobby", + "Music_MobileCenter" + ], + "numOverworldSprites": 102, + "paletteOrder": [ + "PALETTE_AUTO", + "PALETTE_DAY", + "PALETTE_NITE", + "PALETTE_MORN", + "PALETTE_DARK" + ], + "phoneContactOrder": [ + "PHONE_00", + "PHONE_MOM", + "PHONE_OAK", + "PHONE_BILL", + "PHONE_ELM", + "PHONE_SCHOOLBOY_JACK", + "PHONE_POKEFAN_BEVERLY", + "PHONE_SAILOR_HUEY", + "PHONE_UNUSED", + "PHONE_UNUSED", + "PHONE_UNUSED", + "PHONE_COOLTRAINERM_GAVEN", + "PHONE_COOLTRAINERF_BETH", + "PHONE_BIRDKEEPER_JOSE", + "PHONE_COOLTRAINERF_REENA", + "PHONE_YOUNGSTER_JOEY", + "PHONE_BUG_CATCHER_WADE", + "PHONE_FISHER_RALPH", + "PHONE_PICNICKER_LIZ", + "PHONE_HIKER_ANTHONY", + "PHONE_CAMPER_TODD", + "PHONE_PICNICKER_GINA", + "PHONE_JUGGLER_IRWIN", + "PHONE_BUG_CATCHER_ARNIE", + "PHONE_SCHOOLBOY_ALAN", + "PHONE_UNUSED", + "PHONE_LASS_DANA", + "PHONE_SCHOOLBOY_CHAD", + "PHONE_POKEFANM_DEREK", + "PHONE_FISHER_TULLY", + "PHONE_POKEMANIAC_BRENT", + "PHONE_PICNICKER_TIFFANY", + "PHONE_BIRDKEEPER_VANCE", + "PHONE_FISHER_WILTON", + "PHONE_BLACKBELT_KENJI", + "PHONE_HIKER_PARRY", + "PHONE_PICNICKER_ERIN", + "PHONE_BUENA" + ], + "pocketOrder": [ + "ITEM", + "KEY_ITEM", + "BALL", + "TM_HM" + ], + "sfxOrder": [ + "Sfx_DexFanfare5079", + "Sfx_Item", + "Sfx_CaughtMon", + "Sfx_PokeballsPlacedOnTable", + "Sfx_Potion", + "Sfx_FullHeal", + "Sfx_Menu", + "Sfx_ReadText", + "Sfx_ReadText2", + "Sfx_DexFanfare2049", + "Sfx_DexFanfare80109", + "Sfx_Poison", + "Sfx_GotSafariBalls", + "Sfx_BootPc", + "Sfx_ShutDownPc", + "Sfx_ChoosePcOption", + "Sfx_EscapeRope", + "Sfx_PushButton", + "Sfx_SecondPartOfItemfinder", + "Sfx_WarpTo", + "Sfx_WarpFrom", + "Sfx_ChangeDexMode", + "Sfx_JumpOverLedge", + "Sfx_GrassRustle", + "Sfx_Fly", + "Sfx_Wrong", + "Sfx_Squeak", + "Sfx_Strength", + "Sfx_Boat", + "Sfx_WallOpen", + "Sfx_PlacePuzzlePieceDown", + "Sfx_EnterDoor", + "Sfx_SwitchPokemon", + "Sfx_Tally", + "Sfx_Transaction", + "Sfx_ExitBuilding", + "Sfx_Bump", + "Sfx_Save", + "Sfx_Pokeflute", + "Sfx_ElevatorEnd", + "Sfx_ThrowBall", + "Sfx_BallPoof", + "Sfx_Faint", + "Sfx_Run", + "Sfx_SlotMachineStart", + "Sfx_Fanfare", + "Sfx_Peck", + "Sfx_Kinesis", + "Sfx_Lick", + "Sfx_Pound", + "Sfx_MovePuzzlePiece", + "Sfx_CometPunch", + "Sfx_MegaPunch", + "Sfx_Scratch", + "Sfx_Vicegrip", + "Sfx_RazorWind", + "Sfx_Cut", + "Sfx_WingAttack", + "Sfx_Whirlwind", + "Sfx_Bind", + "Sfx_VineWhip", + "Sfx_DoubleKick", + "Sfx_MegaKick", + "Sfx_Headbutt", + "Sfx_HornAttack", + "Sfx_Tackle", + "Sfx_PoisonSting", + "Sfx_Powder", + "Sfx_Doubleslap", + "Sfx_Bite", + "Sfx_JumpKick", + "Sfx_Stomp", + "Sfx_TailWhip", + "Sfx_KarateChop", + "Sfx_Submission", + "Sfx_WaterGun", + "Sfx_SwordsDance", + "Sfx_Thunder", + "Sfx_Supersonic", + "Sfx_Leer", + "Sfx_Ember", + "Sfx_Bubblebeam", + "Sfx_HydroPump", + "Sfx_Surf", + "Sfx_Psybeam", + "Sfx_Charge", + "Sfx_Thundershock", + "Sfx_Psychic", + "Sfx_Screech", + "Sfx_BoneClub", + "Sfx_Sharpen", + "Sfx_EggBomb", + "Sfx_Sing", + "Sfx_HyperBeam", + "Sfx_Shine", + "Sfx_Unknown5F", + "Sfx_Unknown60", + "Sfx_Unknown61", + "Sfx_SwitchPockets", + "Sfx_Unknown63", + "Sfx_Burn", + "Sfx_TitleScreenEntrance", + "Sfx_Unknown66", + "Sfx_GetCoinFromSlots", + "Sfx_PayDay", + "Sfx_Metronome", + "Sfx_Call", + "Sfx_HangUp", + "Sfx_NoSignal", + "Sfx_Sandstorm", + "Sfx_Elevator", + "Sfx_Protect", + "Sfx_Sketch", + "Sfx_RainDance", + "Sfx_Aeroblast", + "Sfx_Spark", + "Sfx_Curse", + "Sfx_Rage", + "Sfx_Thief", + "Sfx_Thief2", + "Sfx_SpiderWeb", + "Sfx_MindReader", + "Sfx_Nightmare", + "Sfx_Snore", + "Sfx_SweetKiss", + "Sfx_SweetKiss2", + "Sfx_BellyDrum", + "Sfx_Toxic", + "Sfx_SludgeBomb", + "Sfx_Foresight", + "Sfx_Spite", + "Sfx_Outrage", + "Sfx_PerishSong", + "Sfx_GigaDrain", + "Sfx_Attract", + "Sfx_Kinesis2", + "Sfx_ZapCannon", + "Sfx_MeanLook", + "Sfx_HealBell", + "Sfx_Return", + "Sfx_ExpBar", + "Sfx_MilkDrink", + "Sfx_Present", + "Sfx_MorningSun", + "Sfx_LevelUp", + "Sfx_KeyItem", + "Sfx_Fanfare2", + "Sfx_RegisterPhoneNumber", + "Sfx_3rdPlace", + "Sfx_GetEgg", + "Sfx_GetEgg", + "Sfx_MoveDeleted", + "Sfx_2ndPlace", + "Sfx_1stPlace", + "Sfx_ChooseACard", + "Sfx_GetTm", + "Sfx_GetBadge", + "Sfx_QuitSlots", + "Sfx_EggCrack", + "Sfx_DexFanfareLessThan20", + "Sfx_DexFanfare140169", + "Sfx_DexFanfare170199", + "Sfx_DexFanfare200229", + "Sfx_DexFanfare230Plus", + "Sfx_Evolved", + "Sfx_MasterBall", + "Sfx_EggHatch", + "Sfx_GsIntroCharizardFireball", + "Sfx_GsIntroPokemonAppears", + "Sfx_Flash", + "Sfx_GameFreakLogoGs", + "Sfx_NotVeryEffective", + "Sfx_Damage", + "Sfx_SuperEffective", + "Sfx_BallBounce", + "Sfx_Moonlight", + "Sfx_Encore", + "Sfx_BeatUp", + "Sfx_BatonPass", + "Sfx_BallWobble", + "Sfx_SweetScent", + "Sfx_SweetScent2", + "Sfx_HitEndOfExpBar", + "Sfx_GiveTrademon", + "Sfx_GetTrademon", + "Sfx_TrainArrived", + "Sfx_StopSlot", + "Sfx_2Boops", + "Sfx_GlassTing", + "Sfx_GlassTing2", + "Sfx_IntroUnown1", + "Sfx_IntroUnown2", + "Sfx_IntroUnown3", + "Sfx_DittoPopUp", + "Sfx_DittoTransform", + "Sfx_IntroSuicune1", + "Sfx_IntroPichu", + "Sfx_IntroSuicune2", + "Sfx_IntroSuicune3", + "Sfx_DittoBounce", + "Sfx_IntroSuicune4", + "Sfx_GameFreakPresents", + "Sfx_Tingle", + "Sfx_IntroWhoosh", + "Sfx_TwoPcBeeps", + "Sfx_4NoteDitty", + "Sfx_Twinkle" + ], + "source": "pret/pokegold constants/*.asm", + "spawnOrder": [ + "SPAWN_HOME", + "SPAWN_DEBUG", + "SPAWN_PALLET", + "SPAWN_VIRIDIAN", + "SPAWN_PEWTER", + "SPAWN_CERULEAN", + "SPAWN_ROCK_TUNNEL", + "SPAWN_VERMILION", + "SPAWN_LAVENDER", + "SPAWN_SAFFRON", + "SPAWN_CELADON", + "SPAWN_FUCHSIA", + "SPAWN_CINNABAR", + "SPAWN_INDIGO", + "SPAWN_NEW_BARK", + "SPAWN_CHERRYGROVE", + "SPAWN_VIOLET", + "SPAWN_UNION_CAVE", + "SPAWN_AZALEA", + "SPAWN_CIANWOOD", + "SPAWN_GOLDENROD", + "SPAWN_OLIVINE", + "SPAWN_ECRUTEAK", + "SPAWN_MAHOGANY", + "SPAWN_LAKE_OF_RAGE", + "SPAWN_BLACKTHORN", + "SPAWN_MT_SILVER", + "SPAWN_FAST_SHIP" + ], + "specialCallOrder": [ + "SPECIALCALL_NONE", + "SPECIALCALL_POKERUS", + "SPECIALCALL_ROBBED", + "SPECIALCALL_ASSISTANT", + "SPECIALCALL_WEIRDBROADCAST", + "SPECIALCALL_SSTICKET", + "SPECIALCALL_BIKESHOP", + "SPECIALCALL_WORRIED", + "SPECIALCALL_MASTERBALL" + ], + "specialOrder": [ + "WarpToSpawnPoint", + "SetBitsForLinkTradeRequest", + "WaitForLinkedFriend", + "CheckLinkTimeout_Receptionist", + "TryQuickSave", + "CheckBothSelectedSameRoom", + "FailedLinkToPast", + "CloseLink", + "WaitForOtherPlayerToExit", + "SetBitsForBattleRequest", + "SetBitsForTimeCapsuleRequest", + "CheckTimeCapsuleCompatibility", + "EnterTimeCapsule", + "TradeCenter", + "Colosseum", + "TimeCapsule", + "CableClubCheckWhichChris", + "CheckMysteryGift", + "GetMysteryGiftItem", + "UnlockMysteryGift", + "BugContestJudging", + "CheckPartyFullAfterContest", + "ContestDropOffMons", + "ContestReturnMons", + "GiveParkBalls", + "CheckMagikarpLength", + "MagikarpHouseSign", + "HealParty", + "PokemonCenterPC", + "PlayersHousePC", + "DayCareMan", + "DayCareLady", + "DayCareManOutside", + "MoveDeletion", + "BankOfMom", + "MagnetTrain", + "NameRival", + "SetDayOfWeek", + "OverworldTownMap", + "UnownPrinter", + "MapRadio", + "UnownPuzzle", + "SlotMachine", + "CardFlip", + "UnusedMemoryGame", + "ClearBGPalettesBufferScreen", + "FadeOutToWhite", + "BattleTowerFade", + "FadeOutToBlack", + "FadeInFromWhite", + "FadeInFromBlack", + "ReloadSpritesNoPalettes", + "ClearBGPalettes", + "UpdateTimePals", + "ClearTilemap", + "UpdateSprites", + "UpdatePlayerSprite", + "GameCornerPrizeMonCheckDex", + "UnusedSetSeenMon", + "WaitSFX", + "PlayMapMusic", + "RestartMapMusic", + "HealMachineAnim", + "SurfStartStep", + "FindPartyMonAboveLevel", + "FindPartyMonAtLeastThatHappy", + "FindPartyMonThatSpecies", + "FindPartyMonThatSpeciesYourTrainerID", + "UnusedCheckUnusedTwoDayTimer", + "DayCareMon1", + "DayCareMon2", + "SelectRandomBugContestContestants", + "ActivateFishingSwarm", + "ToggleMaptileDecorations", + "ToggleDecorationsVisibility", + "GiveShuckle", + "ReturnShuckie", + "BillsGrandfather", + "CheckPokerus", + "DisplayCoinCaseBalance", + "DisplayMoneyAndCoinBalance", + "PlaceMoneyTopRight", + "CheckForLuckyNumberWinners", + "CheckLuckyNumberShowFlag", + "ResetLuckyNumberShowFlag", + "PrintTodaysLuckyNumber", + "SelectApricornForKurt", + "NameRater", + "DisplayLinkRecord", + "GetFirstPokemonHappiness", + "CheckFirstMonIsEgg", + "RandomUnseenWildMon", + "RandomPhoneWildMon", + "RandomPhoneMon", + "LoadUsedSpritesGFX", + "PlaySlowCry", + "SnorlaxAwake", + "OlderHaircutBrother", + "YoungerHaircutBrother", + "DaisysGrooming", + "PlayCurMonCry", + "ProfOaksPCBoot", + "GameboyCheck", + "TrainerHouse", + "PhotoStudio", + "InitRoamMons", + "FadeOutMusic", + "Diploma", + "PrintDiploma", + "Function11ac3e", + "TradeCornerHoldMon", + "Function11b5e8", + "Function11b7e5", + "Function11b879", + "Function11b920", + "Function11b93b", + "BattleTowerRoomMenu", + "Function1700ba", + "Function170114", + "BattleTowerBattle", + "Function1704e1", + "UnusedBattleTowerDummySpecial1", + "LoadOpponentTrainerAndPokemonWithOTSprite", + "Function11ba38", + "CheckForBattleTowerRules", + "GiveOddEgg", + "Reset", + "Function1011f1", + "Function101220", + "Function101225", + "Function101231", + "MoveTutor", + "OmanyteChamber", + "Function11c1ab", + "BattleTowerAction", + "DisplayUnownWords", + "Menu_ChallengeExplanationCancel", + "Function17d2b6", + "Function17d2ce", + "BattleTowerMobileError", + "AskMobileOrCable", + "HoOhChamber", + "Function102142", + "CelebiShrineEvent", + "CheckCaughtCelebi", + "PokeSeer", + "BuenasPassword", + "BuenaPrize", + "GiveDratini", + "SampleKenjiBreakCountdown", + "BeastsCheck", + "MonCheck", + "SetPlayerPalette", + "UnusedBattleTowerDummySpecial2", + "Mobile_SelectThreeMons", + "Function1037eb", + "Function10383c", + "StubbedTrainerRankings_Healings", + "RefreshSprites", + "Function1037c2", + "CheckMobileAdapterStatusSpecial", + "Function103780", + "Function10387b", + "AskRememberPassword", + "LoadMapPalettes", + "UnusedFindItemInPCOrBag", + "InitialSetDSTFlag", + "InitialClearDSTFlag", + "UnusedDummySpecial" + ], + "speciesOrder": [ + "BULBASAUR", + "IVYSAUR", + "VENUSAUR", + "CHARMANDER", + "CHARMELEON", + "CHARIZARD", + "SQUIRTLE", + "WARTORTLE", + "BLASTOISE", + "CATERPIE", + "METAPOD", + "BUTTERFREE", + "WEEDLE", + "KAKUNA", + "BEEDRILL", + "PIDGEY", + "PIDGEOTTO", + "PIDGEOT", + "RATTATA", + "RATICATE", + "SPEAROW", + "FEAROW", + "EKANS", + "ARBOK", + "PIKACHU", + "RAICHU", + "SANDSHREW", + "SANDSLASH", + "NIDORAN_F", + "NIDORINA", + "NIDOQUEEN", + "NIDORAN_M", + "NIDORINO", + "NIDOKING", + "CLEFAIRY", + "CLEFABLE", + "VULPIX", + "NINETALES", + "JIGGLYPUFF", + "WIGGLYTUFF", + "ZUBAT", + "GOLBAT", + "ODDISH", + "GLOOM", + "VILEPLUME", + "PARAS", + "PARASECT", + "VENONAT", + "VENOMOTH", + "DIGLETT", + "DUGTRIO", + "MEOWTH", + "PERSIAN", + "PSYDUCK", + "GOLDUCK", + "MANKEY", + "PRIMEAPE", + "GROWLITHE", + "ARCANINE", + "POLIWAG", + "POLIWHIRL", + "POLIWRATH", + "ABRA", + "KADABRA", + "ALAKAZAM", + "MACHOP", + "MACHOKE", + "MACHAMP", + "BELLSPROUT", + "WEEPINBELL", + "VICTREEBEL", + "TENTACOOL", + "TENTACRUEL", + "GEODUDE", + "GRAVELER", + "GOLEM", + "PONYTA", + "RAPIDASH", + "SLOWPOKE", + "SLOWBRO", + "MAGNEMITE", + "MAGNETON", + "FARFETCH_D", + "DODUO", + "DODRIO", + "SEEL", + "DEWGONG", + "GRIMER", + "MUK", + "SHELLDER", + "CLOYSTER", + "GASTLY", + "HAUNTER", + "GENGAR", + "ONIX", + "DROWZEE", + "HYPNO", + "KRABBY", + "KINGLER", + "VOLTORB", + "ELECTRODE", + "EXEGGCUTE", + "EXEGGUTOR", + "CUBONE", + "MAROWAK", + "HITMONLEE", + "HITMONCHAN", + "LICKITUNG", + "KOFFING", + "WEEZING", + "RHYHORN", + "RHYDON", + "CHANSEY", + "TANGELA", + "KANGASKHAN", + "HORSEA", + "SEADRA", + "GOLDEEN", + "SEAKING", + "STARYU", + "STARMIE", + "MR__MIME", + "SCYTHER", + "JYNX", + "ELECTABUZZ", + "MAGMAR", + "PINSIR", + "TAUROS", + "MAGIKARP", + "GYARADOS", + "LAPRAS", + "DITTO", + "EEVEE", + "VAPOREON", + "JOLTEON", + "FLAREON", + "PORYGON", + "OMANYTE", + "OMASTAR", + "KABUTO", + "KABUTOPS", + "AERODACTYL", + "SNORLAX", + "ARTICUNO", + "ZAPDOS", + "MOLTRES", + "DRATINI", + "DRAGONAIR", + "DRAGONITE", + "MEWTWO", + "MEW", + "CHIKORITA", + "BAYLEEF", + "MEGANIUM", + "CYNDAQUIL", + "QUILAVA", + "TYPHLOSION", + "TOTODILE", + "CROCONAW", + "FERALIGATR", + "SENTRET", + "FURRET", + "HOOTHOOT", + "NOCTOWL", + "LEDYBA", + "LEDIAN", + "SPINARAK", + "ARIADOS", + "CROBAT", + "CHINCHOU", + "LANTURN", + "PICHU", + "CLEFFA", + "IGGLYBUFF", + "TOGEPI", + "TOGETIC", + "NATU", + "XATU", + "MAREEP", + "FLAAFFY", + "AMPHAROS", + "BELLOSSOM", + "MARILL", + "AZUMARILL", + "SUDOWOODO", + "POLITOED", + "HOPPIP", + "SKIPLOOM", + "JUMPLUFF", + "AIPOM", + "SUNKERN", + "SUNFLORA", + "YANMA", + "WOOPER", + "QUAGSIRE", + "ESPEON", + "UMBREON", + "MURKROW", + "SLOWKING", + "MISDREAVUS", + "UNOWN", + "WOBBUFFET", + "GIRAFARIG", + "PINECO", + "FORRETRESS", + "DUNSPARCE", + "GLIGAR", + "STEELIX", + "SNUBBULL", + "GRANBULL", + "QWILFISH", + "SCIZOR", + "SHUCKLE", + "HERACROSS", + "SNEASEL", + "TEDDIURSA", + "URSARING", + "SLUGMA", + "MAGCARGO", + "SWINUB", + "PILOSWINE", + "CORSOLA", + "REMORAID", + "OCTILLERY", + "DELIBIRD", + "MANTINE", + "SKARMORY", + "HOUNDOUR", + "HOUNDOOM", + "KINGDRA", + "PHANPY", + "DONPHAN", + "PORYGON2", + "STANTLER", + "SMEARGLE", + "TYROGUE", + "HITMONTOP", + "SMOOCHUM", + "ELEKID", + "MAGBY", + "MILTANK", + "BLISSEY", + "RAIKOU", + "ENTEI", + "SUICUNE", + "LARVITAR", + "PUPITAR", + "TYRANITAR", + "LUGIA", + "HO_OH", + "CELEBI" + ], + "spriteOrder": [ + "SPRITE_CHRIS", + "SPRITE_CHRIS_BIKE", + "SPRITE_GAMEBOY_KID", + "SPRITE_RIVAL", + "SPRITE_OAK", + "SPRITE_RED", + "SPRITE_BLUE", + "SPRITE_BILL", + "SPRITE_ELDER", + "SPRITE_JANINE", + "SPRITE_KURT", + "SPRITE_MOM", + "SPRITE_BLAINE", + "SPRITE_REDS_MOM", + "SPRITE_DAISY", + "SPRITE_ELM", + "SPRITE_WILL", + "SPRITE_FALKNER", + "SPRITE_WHITNEY", + "SPRITE_BUGSY", + "SPRITE_MORTY", + "SPRITE_CHUCK", + "SPRITE_JASMINE", + "SPRITE_PRYCE", + "SPRITE_CLAIR", + "SPRITE_BROCK", + "SPRITE_KAREN", + "SPRITE_BRUNO", + "SPRITE_MISTY", + "SPRITE_LANCE", + "SPRITE_SURGE", + "SPRITE_ERIKA", + "SPRITE_KOGA", + "SPRITE_SABRINA", + "SPRITE_COOLTRAINER_M", + "SPRITE_COOLTRAINER_F", + "SPRITE_BUG_CATCHER", + "SPRITE_TWIN", + "SPRITE_YOUNGSTER", + "SPRITE_LASS", + "SPRITE_TEACHER", + "SPRITE_BEAUTY", + "SPRITE_SUPER_NERD", + "SPRITE_ROCKER", + "SPRITE_POKEFAN_M", + "SPRITE_POKEFAN_F", + "SPRITE_GRAMPS", + "SPRITE_GRANNY", + "SPRITE_SWIMMER_GUY", + "SPRITE_SWIMMER_GIRL", + "SPRITE_BIG_SNORLAX", + "SPRITE_SURFING_PIKACHU", + "SPRITE_ROCKET", + "SPRITE_ROCKET_GIRL", + "SPRITE_NURSE", + "SPRITE_LINK_RECEPTIONIST", + "SPRITE_CLERK", + "SPRITE_FISHER", + "SPRITE_FISHING_GURU", + "SPRITE_SCIENTIST", + "SPRITE_KIMONO_GIRL", + "SPRITE_SAGE", + "SPRITE_UNUSED_GUY", + "SPRITE_GENTLEMAN", + "SPRITE_BLACK_BELT", + "SPRITE_RECEPTIONIST", + "SPRITE_OFFICER", + "SPRITE_CAL", + "SPRITE_SLOWPOKE", + "SPRITE_CAPTAIN", + "SPRITE_BIG_LAPRAS", + "SPRITE_GYM_GUIDE", + "SPRITE_SAILOR", + "SPRITE_BIKER", + "SPRITE_PHARMACIST", + "SPRITE_MONSTER", + "SPRITE_FAIRY", + "SPRITE_BIRD", + "SPRITE_DRAGON", + "SPRITE_BIG_ONIX", + "SPRITE_N64", + "SPRITE_SUDOWOODO", + "SPRITE_SURF", + "SPRITE_POKE_BALL", + "SPRITE_POKEDEX", + "SPRITE_PAPER", + "SPRITE_VIRTUAL_BOY", + "SPRITE_OLD_LINK_RECEPTIONIST", + "SPRITE_ROCK", + "SPRITE_BOULDER", + "SPRITE_SNES", + "SPRITE_FAMICOM", + "SPRITE_FRUIT_TREE", + "SPRITE_GOLD_TROPHY", + "SPRITE_SILVER_TROPHY", + "SPRITE_KRIS", + "SPRITE_KRIS_BIKE", + "SPRITE_KURT_OUTSIDE", + "SPRITE_SUICUNE", + "SPRITE_ENTEI", + "SPRITE_RAIKOU", + "SPRITE_STANDING_YOUNGSTER", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "SPRITE_UNOWN", + "SPRITE_GEODUDE", + "SPRITE_GROWLITHE", + "SPRITE_WEEDLE", + "SPRITE_SHELLDER", + "SPRITE_ODDISH", + "SPRITE_GENGAR", + "SPRITE_ZUBAT", + "SPRITE_MAGIKARP", + "SPRITE_SQUIRTLE", + "SPRITE_TOGEPI", + "SPRITE_BUTTERFREE", + "SPRITE_DIGLETT", + "SPRITE_POLIWAG", + "SPRITE_PIKACHU", + "SPRITE_CLEFAIRY", + "SPRITE_CHARMANDER", + "SPRITE_JYNX", + "SPRITE_STARMIE", + "SPRITE_BULBASAUR", + "SPRITE_JIGGLYPUFF", + "SPRITE_GRIMER", + "SPRITE_EKANS", + "SPRITE_PARAS", + "SPRITE_TENTACOOL", + "SPRITE_TAUROS", + "SPRITE_MACHOP", + "SPRITE_VOLTORB", + "SPRITE_LAPRAS", + "SPRITE_RHYDON", + "SPRITE_MOLTRES", + "SPRITE_SNORLAX", + "SPRITE_GYARADOS", + "SPRITE_LUGIA", + "SPRITE_HO_OH" + ], + "spritePokemon": 128, + "stdScriptOrder": [ + "PokecenterNurseScript", + "DifficultBookshelfScript", + "PictureBookshelfScript", + "MagazineBookshelfScript", + "TeamRocketOathScript", + "IncenseBurnerScript", + "MerchandiseShelfScript", + "TownMapScript", + "WindowScript", + "TVScript", + "HomepageScript", + "Radio1Script", + "Radio2Script", + "TrashCanScript", + "StrengthBoulderScript", + "SmashRockScript", + "PokecenterSignScript", + "MartSignScript", + "GoldenrodRocketsScript", + "RadioTowerRocketsScript", + "ElevatorButtonScript", + "DayToTextScript", + "BugContestResultsWarpScript", + "BugContestResultsScript", + "InitializeEventsScript", + "AskNumber1MScript", + "AskNumber2MScript", + "RegisteredNumberMScript", + "NumberAcceptedMScript", + "NumberDeclinedMScript", + "PhoneFullMScript", + "RematchMScript", + "GiftMScript", + "PackFullMScript", + "RematchGiftMScript", + "AskNumber1FScript", + "AskNumber2FScript", + "RegisteredNumberFScript", + "NumberAcceptedFScript", + "NumberDeclinedFScript", + "PhoneFullFScript", + "RematchFScript", + "GiftFScript", + "PackFullFScript", + "RematchGiftFScript", + "GymStatue1Script", + "GymStatue2Script", + "ReceiveItemScript", + "ReceiveTogepiEggScript", + "PCScript", + "GameCornerCoinVendorScript", + "HappinessCheckScript" + ], + "tilesetOrder": [ + "TILESET_JOHTO", + "TILESET_JOHTO_MODERN", + "TILESET_KANTO", + "TILESET_BATTLE_TOWER_OUTSIDE", + "TILESET_HOUSE", + "TILESET_PLAYERS_HOUSE", + "TILESET_POKECENTER", + "TILESET_GATE", + "TILESET_PORT", + "TILESET_LAB", + "TILESET_FACILITY", + "TILESET_MART", + "TILESET_MANSION", + "TILESET_GAME_CORNER", + "TILESET_ELITE_FOUR_ROOM", + "TILESET_TRADITIONAL_HOUSE", + "TILESET_TRAIN_STATION", + "TILESET_CHAMPIONS_ROOM", + "TILESET_LIGHTHOUSE", + "TILESET_PLAYERS_ROOM", + "TILESET_POKECOM_CENTER", + "TILESET_BATTLE_TOWER_INSIDE", + "TILESET_TOWER", + "TILESET_CAVE", + "TILESET_PARK", + "TILESET_RUINS_OF_ALPH", + "TILESET_RADIO_TOWER", + "TILESET_UNDERGROUND", + "TILESET_ICE_PATH", + "TILESET_DARK_CAVE", + "TILESET_FOREST", + "TILESET_BETA_WORD_ROOM", + "TILESET_HO_OH_WORD_ROOM", + "TILESET_KABUTO_WORD_ROOM", + "TILESET_OMANYTE_WORD_ROOM", + "TILESET_AERODACTYL_WORD_ROOM" + ], + "tradeDialogOrder": [ + "TRADE_DIALOGSET_COLLECTOR", + "TRADE_DIALOGSET_HAPPY", + "TRADE_DIALOGSET_NEWBIE", + "TRADE_DIALOGSET_GIRL" + ], + "tradeGenderOrder": [ + "TRADE_GENDER_EITHER", + "TRADE_GENDER_MALE", + "TRADE_GENDER_FEMALE" + ], + "trainerClassMembers": { + "BEAUTY": [ + "VICTORIA", + "SAMANTHA", + "JULIE", + "JACLYN", + "BRENDA", + "CASSIE", + "CAROLINE", + "CARLENE", + "JESSICA", + "RACHAEL", + "ANGELICA", + "KENDRA", + "VERONICA", + "JULIA", + "THERESA", + "VALERIE", + "OLIVIA" + ], + "BIKER": [ + "BIKER_BENNY", + "KAZU", + "DWAYNE", + "HARRIS", + "ZEKE", + "CHARLES", + "RILEY", + "JOEL", + "GLENN" + ], + "BIRD_KEEPER": [ + "ROD", + "ABE", + "BRYAN", + "THEO", + "TOBY", + "DENIS", + "VANCE1", + "HANK", + "ROY", + "BORIS", + "BOB", + "JOSE1", + "PETER", + "JOSE2", + "PERRY", + "BRET", + "JOSE3", + "VANCE2", + "VANCE3" + ], + "BLACKBELT_T": [ + "KENJI1", + "YOSHI", + "KENJI2", + "LAO", + "NOB", + "KIYO", + "LUNG", + "KENJI3", + "WAI" + ], + "BLAINE": [ + "BLAINE1" + ], + "BLUE": [ + "BLUE1" + ], + "BOARDER": [ + "RONALD", + "BRAD", + "DOUGLAS" + ], + "BROCK": [ + "BROCK1" + ], + "BRUNO": [ + "BRUNO1" + ], + "BUGSY": [ + "BUGSY1" + ], + "BUG_CATCHER": [ + "DON", + "ROB", + "ED", + "WADE1", + "BUG_CATCHER_BENNY", + "AL", + "JOSH", + "ARNIE1", + "KEN", + "WADE2", + "WADE3", + "DOUG", + "ARNIE2", + "ARNIE3", + "WADE4", + "WADE5", + "ARNIE4", + "ARNIE5", + "WAYNE" + ], + "BURGLAR": [ + "DUNCAN", + "EDDIE", + "COREY" + ], + "CAL": [ + "CAL1", + "CAL2", + "CAL3" + ], + "CAMPER": [ + "ROLAND", + "TODD1", + "IVAN", + "ELLIOT", + "BARRY", + "LLOYD", + "DEAN", + "SID", + "HARVEY", + "DALE", + "TED", + "TODD2", + "TODD3", + "THOMAS", + "LEROY", + "DAVID", + "JOHN", + "JERRY", + "SPENCER", + "TODD4", + "TODD5", + "QUENTIN" + ], + "CHAMPION": [ + "LANCE" + ], + "CHUCK": [ + "CHUCK1" + ], + "CLAIR": [ + "CLAIR1" + ], + "COOLTRAINERF": [ + "GWEN", + "LOIS", + "FRAN", + "LOLA", + "KATE", + "IRENE", + "KELLY", + "JOYCE", + "BETH1", + "REENA1", + "MEGAN", + "BETH2", + "CAROL", + "QUINN", + "EMMA", + "CYBIL", + "JENN", + "BETH3", + "REENA2", + "REENA3", + "CARA" + ], + "COOLTRAINERM": [ + "NICK", + "AARON", + "PAUL", + "CODY", + "MIKE", + "GAVEN1", + "GAVEN2", + "RYAN", + "JAKE", + "GAVEN3", + "BLAKE", + "BRIAN", + "ERICK", + "ANDY", + "TYLER", + "SEAN", + "KEVIN", + "STEVE", + "ALLEN", + "DARIN" + ], + "ERIKA": [ + "ERIKA1" + ], + "EXECUTIVEF": [ + "EXECUTIVEF_1", + "EXECUTIVEF_2" + ], + "EXECUTIVEM": [ + "EXECUTIVEM_1", + "EXECUTIVEM_2", + "EXECUTIVEM_3", + "EXECUTIVEM_4" + ], + "FALKNER": [ + "FALKNER1" + ], + "FIREBREATHER": [ + "OTIS", + "DICK", + "NED", + "BURT", + "BILL", + "WALT", + "RAY", + "LYLE" + ], + "FISHER": [ + "JUSTIN", + "RALPH1", + "ARNOLD", + "KYLE", + "HENRY", + "MARVIN", + "TULLY1", + "ANDRE", + "RAYMOND", + "WILTON1", + "EDGAR", + "JONAH", + "MARTIN", + "STEPHEN", + "BARNEY", + "RALPH2", + "RALPH3", + "TULLY2", + "TULLY3", + "WILTON2", + "SCOTT", + "WILTON3", + "RALPH4", + "RALPH5", + "TULLY4" + ], + "GENTLEMAN": [ + "PRESTON", + "EDWARD", + "GREGORY", + "VIRGIL", + "ALFRED" + ], + "GRUNTF": [ + "GRUNTF_1", + "GRUNTF_2", + "GRUNTF_3", + "GRUNTF_4", + "GRUNTF_5" + ], + "GRUNTM": [ + "GRUNTM_1", + "GRUNTM_2", + "GRUNTM_3", + "GRUNTM_4", + "GRUNTM_5", + "GRUNTM_6", + "GRUNTM_7", + "GRUNTM_8", + "GRUNTM_9", + "GRUNTM_10", + "GRUNTM_11", + "GRUNTM_12", + "GRUNTM_13", + "GRUNTM_14", + "GRUNTM_15", + "GRUNTM_16", + "GRUNTM_17", + "GRUNTM_18", + "GRUNTM_19", + "GRUNTM_20", + "GRUNTM_21", + "GRUNTM_22", + "GRUNTM_23", + "GRUNTM_24", + "GRUNTM_25", + "GRUNTM_26", + "GRUNTM_27", + "GRUNTM_28", + "GRUNTM_29", + "GRUNTM_30", + "GRUNTM_31" + ], + "GUITARIST": [ + "CLYDE", + "VINCENT" + ], + "HIKER": [ + "ANTHONY1", + "RUSSELL", + "PHILLIP", + "LEONARD", + "ANTHONY2", + "BENJAMIN", + "ERIK", + "MICHAEL", + "PARRY1", + "TIMOTHY", + "BAILEY", + "ANTHONY3", + "TIM", + "NOLAND", + "SIDNEY", + "KENNY", + "JIM", + "DANIEL", + "PARRY2", + "PARRY3", + "ANTHONY4", + "ANTHONY5" + ], + "JANINE": [ + "JANINE1" + ], + "JASMINE": [ + "JASMINE1" + ], + "JUGGLER": [ + "IRWIN1", + "FRITZ", + "HORTON", + "IRWIN2", + "IRWIN3", + "IRWIN4" + ], + "KAREN": [ + "KAREN1" + ], + "KIMONO_GIRL": [ + "NAOKO_UNUSED", + "NAOKO", + "SAYO", + "ZUKI", + "KUNI", + "MIKI" + ], + "KOGA": [ + "KOGA1" + ], + "LASS": [ + "CARRIE", + "BRIDGET", + "ALICE", + "KRISE", + "CONNIE1", + "LINDA", + "LAURA", + "SHANNON", + "MICHELLE", + "DANA1", + "ELLEN", + "CONNIE2", + "CONNIE3", + "DANA2", + "DANA3", + "DANA4", + "DANA5" + ], + "LT_SURGE": [ + "LT_SURGE1" + ], + "MEDIUM": [ + "MARTHA", + "GRACE", + "BETHANY", + "MARGRET", + "ETHEL", + "REBECCA", + "DORIS" + ], + "MISTY": [ + "MISTY1" + ], + "MORTY": [ + "MORTY1" + ], + "MYSTICALMAN": [ + "EUSINE" + ], + "OFFICER": [ + "KEITH", + "DIRK" + ], + "PICNICKER": [ + "LIZ1", + "GINA1", + "BROOKE", + "KIM", + "CINDY", + "HOPE", + "SHARON", + "DEBRA", + "GINA2", + "ERIN1", + "LIZ2", + "LIZ3", + "HEIDI", + "EDNA", + "GINA3", + "TIFFANY1", + "TIFFANY2", + "ERIN2", + "TANYA", + "TIFFANY3", + "ERIN3", + "LIZ4", + "LIZ5", + "GINA4", + "GINA5", + "TIFFANY4" + ], + "POKEFANF": [ + "BEVERLY1", + "RUTH", + "BEVERLY2", + "BEVERLY3", + "GEORGIA", + "JAIME" + ], + "POKEFANM": [ + "WILLIAM", + "DEREK1", + "ROBERT", + "JOSHUA", + "CARTER", + "TREVOR", + "BRANDON", + "JEREMY", + "COLIN", + "DEREK2", + "DEREK3", + "ALEX", + "REX", + "ALLAN" + ], + "POKEMANIAC": [ + "LARRY", + "ANDREW", + "CALVIN", + "SHANE", + "BEN", + "BRENT1", + "RON", + "ETHAN", + "BRENT2", + "BRENT3", + "ISSAC", + "DONALD", + "ZACH", + "BRENT4", + "MILLER" + ], + "POKEMON_PROF": [], + "PRYCE": [ + "PRYCE1" + ], + "PSYCHIC_T": [ + "NATHAN", + "FRANKLIN", + "HERMAN", + "FIDEL", + "GREG", + "NORMAN", + "MARK", + "PHIL", + "RICHARD", + "GILBERT", + "JARED", + "RODNEY" + ], + "RED": [ + "RED1" + ], + "RIVAL1": [ + "RIVAL1_1_CHIKORITA", + "RIVAL1_1_CYNDAQUIL", + "RIVAL1_1_TOTODILE", + "RIVAL1_2_CHIKORITA", + "RIVAL1_2_CYNDAQUIL", + "RIVAL1_2_TOTODILE", + "RIVAL1_3_CHIKORITA", + "RIVAL1_3_CYNDAQUIL", + "RIVAL1_3_TOTODILE", + "RIVAL1_4_CHIKORITA", + "RIVAL1_4_CYNDAQUIL", + "RIVAL1_4_TOTODILE", + "RIVAL1_5_CHIKORITA", + "RIVAL1_5_CYNDAQUIL", + "RIVAL1_5_TOTODILE" + ], + "RIVAL2": [ + "RIVAL2_1_CHIKORITA", + "RIVAL2_1_CYNDAQUIL", + "RIVAL2_1_TOTODILE", + "RIVAL2_2_CHIKORITA", + "RIVAL2_2_CYNDAQUIL", + "RIVAL2_2_TOTODILE" + ], + "SABRINA": [ + "SABRINA1" + ], + "SAGE": [ + "CHOW", + "NICO", + "JIN", + "TROY", + "JEFFREY", + "PING", + "EDMOND", + "NEAL", + "LI", + "GAKU", + "MASA", + "KOJI" + ], + "SAILOR": [ + "EUGENE", + "HUEY1", + "TERRELL", + "KENT", + "ERNEST", + "JEFF", + "GARRETT", + "KENNETH", + "STANLY", + "HARRY", + "HUEY2", + "HUEY3", + "HUEY4" + ], + "SCHOOLBOY": [ + "JACK1", + "KIPP", + "ALAN1", + "JOHNNY", + "DANNY", + "TOMMY", + "DUDLEY", + "JOE", + "BILLY", + "CHAD1", + "NATE", + "RICKY", + "JACK2", + "JACK3", + "ALAN2", + "ALAN3", + "CHAD2", + "CHAD3", + "JACK4", + "JACK5", + "ALAN4", + "ALAN5", + "CHAD4", + "CHAD5" + ], + "SCIENTIST": [ + "ROSS", + "MITCH", + "JED", + "MARC", + "RICH" + ], + "SKIER": [ + "ROXANNE", + "CLARISSA" + ], + "SUPER_NERD": [ + "STAN", + "ERIC", + "GREGG", + "JAY", + "DAVE", + "SAM", + "TOM", + "PAT", + "SHAWN", + "TERU", + "RUSS", + "NORTON", + "HUGH", + "MARKUS" + ], + "SWIMMERF": [ + "ELAINE", + "PAULA", + "KAYLEE", + "SUSIE", + "DENISE", + "KARA", + "WENDY", + "LISA", + "JILL", + "MARY", + "KATIE", + "DAWN", + "TARA", + "NICOLE", + "LORI", + "JODY", + "NIKKI", + "DIANA", + "BRIANA" + ], + "SWIMMERM": [ + "HAROLD", + "SIMON", + "RANDALL", + "CHARLIE", + "GEORGE", + "BERKE", + "KIRK", + "MATHEW", + "HAL", + "PATON", + "DARYL", + "WALTER", + "TONY", + "JEROME", + "TUCKER", + "RICK", + "CAMERON", + "SETH", + "JAMES", + "LEWIS", + "PARKER" + ], + "TEACHER": [ + "COLETTE", + "HILLARY", + "SHIRLEY" + ], + "TRAINER_NONE": [], + "TWINS": [ + "AMYANDMAY1", + "ANNANDANNE1", + "ANNANDANNE2", + "AMYANDMAY2", + "JOANDZOE1", + "JOANDZOE2", + "MEGANDPEG1", + "MEGANDPEG2", + "LEAANDPIA1", + "LEAANDPIA2" + ], + "WHITNEY": [ + "WHITNEY1" + ], + "WILL": [ + "WILL1" + ], + "YOUNGSTER": [ + "JOEY1", + "MIKEY", + "ALBERT", + "GORDON", + "SAMUEL", + "IAN", + "JOEY2", + "JOEY3", + "WARREN", + "JIMMY", + "OWEN", + "JASON", + "JOEY4", + "JOEY5" + ] + }, + "trainerClassOrder": [ + "TRAINER_NONE", + "FALKNER", + "WHITNEY", + "BUGSY", + "MORTY", + "PRYCE", + "JASMINE", + "CHUCK", + "CLAIR", + "RIVAL1", + "POKEMON_PROF", + "WILL", + "CAL", + "BRUNO", + "KAREN", + "KOGA", + "CHAMPION", + "BROCK", + "MISTY", + "LT_SURGE", + "SCIENTIST", + "ERIKA", + "YOUNGSTER", + "SCHOOLBOY", + "BIRD_KEEPER", + "LASS", + "JANINE", + "COOLTRAINERM", + "COOLTRAINERF", + "BEAUTY", + "POKEMANIAC", + "GRUNTM", + "GENTLEMAN", + "SKIER", + "TEACHER", + "SABRINA", + "BUG_CATCHER", + "FISHER", + "SWIMMERM", + "SWIMMERF", + "SAILOR", + "SUPER_NERD", + "RIVAL2", + "GUITARIST", + "HIKER", + "BIKER", + "BLAINE", + "BURGLAR", + "FIREBREATHER", + "JUGGLER", + "BLACKBELT_T", + "EXECUTIVEM", + "PSYCHIC_T", + "PICNICKER", + "CAMPER", + "EXECUTIVEF", + "SAGE", + "MEDIUM", + "BOARDER", + "POKEFANM", + "KIMONO_GIRL", + "TWINS", + "POKEFANF", + "RED", + "BLUE", + "OFFICER", + "GRUNTF", + "MYSTICALMAN" + ], + "trainerTypeOrder": [ + "TRAINERTYPE_NORMAL", + "TRAINERTYPE_MOVES", + "TRAINERTYPE_ITEM", + "TRAINERTYPE_ITEM_MOVES" + ], + "treeMonSetOrder": [ + "TREEMON_SET_NONE", + "TREEMON_SET_CANYON", + "TREEMON_SET_TOWN", + "TREEMON_SET_ROUTE", + "TREEMON_SET_KANTO", + "TREEMON_SET_LAKE", + "TREEMON_SET_FOREST", + "TREEMON_SET_ROCK" + ], + "types": { + "BIRD": 6, + "BUG": 7, + "CURSE_TYPE": 19, + "DARK": 27, + "DRAGON": 26, + "ELECTRIC": 23, + "FIGHTING": 1, + "FIRE": 20, + "FLYING": 2, + "GHOST": 8, + "GRASS": 22, + "GROUND": 4, + "ICE": 25, + "NORMAL": 0, + "POISON": 3, + "PSYCHIC_TYPE": 24, + "ROCK": 5, + "STEEL": 9, + "WATER": 21 + } + }, + "fontCharmap": [ + { + "code": 96, + "seq": "" + }, + { + "code": 97, + "seq": "" + }, + { + "code": 98, + "seq": "" + }, + { + "code": 99, + "seq": "" + }, + { + "code": 100, + "seq": "" + }, + { + "code": 101, + "seq": "" + }, + { + "code": 102, + "seq": "" + }, + { + "code": 103, + "seq": "" + }, + { + "code": 104, + "seq": "" + }, + { + "code": 107, + "seq": "" + }, + { + "code": 108, + "seq": "" + }, + { + "code": 106, + "seq": "" + }, + { + "code": 105, + "seq": "" + }, + { + "code": 109, + "seq": "" + }, + { + "code": 242, + "seq": "" + }, + { + "code": 112, + "seq": "" + }, + { + "code": 115, + "seq": "" + }, + { + "code": 113, + "seq": "" + }, + { + "code": 110, + "seq": "" + }, + { + "code": 226, + "seq": "" + }, + { + "code": 225, + "seq": "" + }, + { + "code": 112, + "seq": "" + }, + { + "code": 208, + "seq": "'d" + }, + { + "code": 209, + "seq": "'l" + }, + { + "code": 210, + "seq": "'m" + }, + { + "code": 211, + "seq": "'r" + }, + { + "code": 212, + "seq": "'s" + }, + { + "code": 213, + "seq": "'t" + }, + { + "code": 214, + "seq": "'v" + }, + { + "code": 127, + "seq": " " + }, + { + "code": 231, + "seq": "!" + }, + { + "code": 115, + "seq": "\"" + }, + { + "code": 233, + "seq": "&" + }, + { + "code": 224, + "seq": "'" + }, + { + "code": 154, + "seq": "(" + }, + { + "code": 155, + "seq": ")" + }, + { + "code": 244, + "seq": "," + }, + { + "code": 227, + "seq": "-" + }, + { + "code": 232, + "seq": "." + }, + { + "code": 243, + "seq": "/" + }, + { + "code": 246, + "seq": "0" + }, + { + "code": 247, + "seq": "1" + }, + { + "code": 248, + "seq": "2" + }, + { + "code": 249, + "seq": "3" + }, + { + "code": 250, + "seq": "4" + }, + { + "code": 251, + "seq": "5" + }, + { + "code": 252, + "seq": "6" + }, + { + "code": 253, + "seq": "7" + }, + { + "code": 254, + "seq": "8" + }, + { + "code": 255, + "seq": "9" + }, + { + "code": 156, + "seq": ":" + }, + { + "code": 157, + "seq": ";" + }, + { + "code": 230, + "seq": "?" + }, + { + "code": 128, + "seq": "A" + }, + { + "code": 129, + "seq": "B" + }, + { + "code": 130, + "seq": "C" + }, + { + "code": 131, + "seq": "D" + }, + { + "code": 132, + "seq": "E" + }, + { + "code": 133, + "seq": "F" + }, + { + "code": 134, + "seq": "G" + }, + { + "code": 135, + "seq": "H" + }, + { + "code": 136, + "seq": "I" + }, + { + "code": 137, + "seq": "J" + }, + { + "code": 138, + "seq": "K" + }, + { + "code": 139, + "seq": "L" + }, + { + "code": 140, + "seq": "M" + }, + { + "code": 141, + "seq": "N" + }, + { + "code": 142, + "seq": "O" + }, + { + "code": 143, + "seq": "P" + }, + { + "code": 144, + "seq": "Q" + }, + { + "code": 145, + "seq": "R" + }, + { + "code": 146, + "seq": "S" + }, + { + "code": 147, + "seq": "T" + }, + { + "code": 148, + "seq": "U" + }, + { + "code": 149, + "seq": "V" + }, + { + "code": 150, + "seq": "W" + }, + { + "code": 151, + "seq": "X" + }, + { + "code": 152, + "seq": "Y" + }, + { + "code": 153, + "seq": "Z" + }, + { + "code": 158, + "seq": "[" + }, + { + "code": 159, + "seq": "]" + }, + { + "code": 160, + "seq": "a" + }, + { + "code": 161, + "seq": "b" + }, + { + "code": 162, + "seq": "c" + }, + { + "code": 163, + "seq": "d" + }, + { + "code": 164, + "seq": "e" + }, + { + "code": 165, + "seq": "f" + }, + { + "code": 166, + "seq": "g" + }, + { + "code": 167, + "seq": "h" + }, + { + "code": 168, + "seq": "i" + }, + { + "code": 169, + "seq": "j" + }, + { + "code": 170, + "seq": "k" + }, + { + "code": 171, + "seq": "l" + }, + { + "code": 172, + "seq": "m" + }, + { + "code": 173, + "seq": "n" + }, + { + "code": 174, + "seq": "o" + }, + { + "code": 175, + "seq": "p" + }, + { + "code": 176, + "seq": "q" + }, + { + "code": 177, + "seq": "r" + }, + { + "code": 178, + "seq": "s" + }, + { + "code": 179, + "seq": "t" + }, + { + "code": 180, + "seq": "u" + }, + { + "code": 181, + "seq": "v" + }, + { + "code": 182, + "seq": "w" + }, + { + "code": 183, + "seq": "x" + }, + { + "code": 184, + "seq": "y" + }, + { + "code": 185, + "seq": "z" + }, + { + "code": 240, + "seq": "¥" + }, + { + "code": 116, + "seq": "·" + }, + { + "code": 192, + "seq": "Ä" + }, + { + "code": 193, + "seq": "Ö" + }, + { + "code": 241, + "seq": "×" + }, + { + "code": 194, + "seq": "Ü" + }, + { + "code": 195, + "seq": "ä" + }, + { + "code": 234, + "seq": "é" + }, + { + "code": 196, + "seq": "ö" + }, + { + "code": 197, + "seq": "ü" + }, + { + "code": 114, + "seq": "“" + }, + { + "code": 115, + "seq": "”" + }, + { + "code": 117, + "seq": "…" + }, + { + "code": 110, + "seq": "′" + }, + { + "code": 111, + "seq": "″" + }, + { + "code": 116, + "seq": "№" + }, + { + "code": 223, + "seq": "←" + }, + { + "code": 235, + "seq": "→" + }, + { + "code": 117, + "seq": "⋯" + }, + { + "code": 122, + "seq": "─" + }, + { + "code": 124, + "seq": "│" + }, + { + "code": 121, + "seq": "┌" + }, + { + "code": 123, + "seq": "┐" + }, + { + "code": 125, + "seq": "└" + }, + { + "code": 126, + "seq": "┘" + }, + { + "code": 96, + "seq": "■" + }, + { + "code": 97, + "seq": "▲" + }, + { + "code": 237, + "seq": "▶" + }, + { + "code": 236, + "seq": "▷" + }, + { + "code": 238, + "seq": "▼" + }, + { + "code": 113, + "seq": "◀" + }, + { + "code": 98, + "seq": "☎" + }, + { + "code": 245, + "seq": "♀" + }, + { + "code": 239, + "seq": "♂" + }, + { + "code": 127, + "seq": " " + }, + { + "code": 232, + "seq": "。" + }, + { + "code": 112, + "seq": "「" + }, + { + "code": 113, + "seq": "」" + }, + { + "code": 114, + "seq": "『" + }, + { + "code": 115, + "seq": "』" + }, + { + "code": 118, + "seq": "ぁ" + }, + { + "code": 177, + "seq": "あ" + }, + { + "code": 110, + "seq": "ぃ" + }, + { + "code": 178, + "seq": "い" + }, + { + "code": 111, + "seq": "ぅ" + }, + { + "code": 179, + "seq": "う" + }, + { + "code": 119, + "seq": "ぇ" + }, + { + "code": 180, + "seq": "え" + }, + { + "code": 120, + "seq": "ぉ" + }, + { + "code": 181, + "seq": "お" + }, + { + "code": 182, + "seq": "か" + }, + { + "code": 183, + "seq": "き" + }, + { + "code": 184, + "seq": "く" + }, + { + "code": 185, + "seq": "け" + }, + { + "code": 186, + "seq": "こ" + }, + { + "code": 187, + "seq": "さ" + }, + { + "code": 188, + "seq": "し" + }, + { + "code": 189, + "seq": "す" + }, + { + "code": 190, + "seq": "せ" + }, + { + "code": 191, + "seq": "そ" + }, + { + "code": 192, + "seq": "た" + }, + { + "code": 193, + "seq": "ち" + }, + { + "code": 223, + "seq": "っ" + }, + { + "code": 194, + "seq": "つ" + }, + { + "code": 195, + "seq": "て" + }, + { + "code": 196, + "seq": "と" + }, + { + "code": 197, + "seq": "な" + }, + { + "code": 198, + "seq": "に" + }, + { + "code": 199, + "seq": "ぬ" + }, + { + "code": 200, + "seq": "ね" + }, + { + "code": 201, + "seq": "の" + }, + { + "code": 202, + "seq": "は" + }, + { + "code": 203, + "seq": "ひ" + }, + { + "code": 204, + "seq": "ふ" + }, + { + "code": 205, + "seq": "へ" + }, + { + "code": 206, + "seq": "ほ" + }, + { + "code": 207, + "seq": "ま" + }, + { + "code": 208, + "seq": "み" + }, + { + "code": 209, + "seq": "む" + }, + { + "code": 210, + "seq": "め" + }, + { + "code": 211, + "seq": "も" + }, + { + "code": 224, + "seq": "ゃ" + }, + { + "code": 212, + "seq": "や" + }, + { + "code": 225, + "seq": "ゅ" + }, + { + "code": 213, + "seq": "ゆ" + }, + { + "code": 226, + "seq": "ょ" + }, + { + "code": 214, + "seq": "よ" + }, + { + "code": 215, + "seq": "ら" + }, + { + "code": 216, + "seq": "り" + }, + { + "code": 217, + "seq": "る" + }, + { + "code": 218, + "seq": "れ" + }, + { + "code": 219, + "seq": "ろ" + }, + { + "code": 220, + "seq": "わ" + }, + { + "code": 221, + "seq": "を" + }, + { + "code": 222, + "seq": "ん" + }, + { + "code": 233, + "seq": "ァ" + }, + { + "code": 128, + "seq": "ア" + }, + { + "code": 176, + "seq": "ィ" + }, + { + "code": 129, + "seq": "イ" + }, + { + "code": 234, + "seq": "ゥ" + }, + { + "code": 130, + "seq": "ウ" + }, + { + "code": 235, + "seq": "ェ" + }, + { + "code": 131, + "seq": "エ" + }, + { + "code": 244, + "seq": "ォ" + }, + { + "code": 132, + "seq": "オ" + }, + { + "code": 133, + "seq": "カ" + }, + { + "code": 134, + "seq": "キ" + }, + { + "code": 135, + "seq": "ク" + }, + { + "code": 136, + "seq": "ケ" + }, + { + "code": 137, + "seq": "コ" + }, + { + "code": 138, + "seq": "サ" + }, + { + "code": 139, + "seq": "シ" + }, + { + "code": 140, + "seq": "ス" + }, + { + "code": 141, + "seq": "セ" + }, + { + "code": 142, + "seq": "ソ" + }, + { + "code": 143, + "seq": "タ" + }, + { + "code": 144, + "seq": "チ" + }, + { + "code": 172, + "seq": "ッ" + }, + { + "code": 145, + "seq": "ツ" + }, + { + "code": 146, + "seq": "テ" + }, + { + "code": 147, + "seq": "ト" + }, + { + "code": 148, + "seq": "ナ" + }, + { + "code": 149, + "seq": "ニ" + }, + { + "code": 150, + "seq": "ヌ" + }, + { + "code": 151, + "seq": "ネ" + }, + { + "code": 152, + "seq": "ノ" + }, + { + "code": 153, + "seq": "ハ" + }, + { + "code": 154, + "seq": "ヒ" + }, + { + "code": 155, + "seq": "フ" + }, + { + "code": 156, + "seq": "ホ" + }, + { + "code": 157, + "seq": "マ" + }, + { + "code": 158, + "seq": "ミ" + }, + { + "code": 159, + "seq": "ム" + }, + { + "code": 160, + "seq": "メ" + }, + { + "code": 161, + "seq": "モ" + }, + { + "code": 173, + "seq": "ャ" + }, + { + "code": 162, + "seq": "ヤ" + }, + { + "code": 174, + "seq": "ュ" + }, + { + "code": 163, + "seq": "ユ" + }, + { + "code": 175, + "seq": "ョ" + }, + { + "code": 164, + "seq": "ヨ" + }, + { + "code": 165, + "seq": "ラ" + }, + { + "code": 166, + "seq": "ル" + }, + { + "code": 167, + "seq": "レ" + }, + { + "code": 168, + "seq": "ロ" + }, + { + "code": 169, + "seq": "ワ" + }, + { + "code": 170, + "seq": "ヲ" + }, + { + "code": 171, + "seq": "ン" + }, + { + "code": 116, + "seq": "・" + }, + { + "code": 227, + "seq": "ー" + }, + { + "code": 240, + "seq": "円" + }, + { + "code": 231, + "seq": "!" + }, + { + "code": 242, + "seq": "." + }, + { + "code": 243, + "seq": "/" + }, + { + "code": 246, + "seq": "0" + }, + { + "code": 247, + "seq": "1" + }, + { + "code": 248, + "seq": "2" + }, + { + "code": 249, + "seq": "3" + }, + { + "code": 250, + "seq": "4" + }, + { + "code": 251, + "seq": "5" + }, + { + "code": 252, + "seq": "6" + }, + { + "code": 253, + "seq": "7" + }, + { + "code": 254, + "seq": "8" + }, + { + "code": 255, + "seq": "9" + }, + { + "code": 230, + "seq": "?" + }, + { + "code": 229, + "seq": "゙" + }, + { + "code": 228, + "seq": "゚" + } + ], + "format": 3, + "generation": 2, + "maps": { + "AZALEA_GYM": { + "group": 8, + "height": 8, + "map": 5, + "name": "AZALEA_GYM", + "width": 5 + }, + "AZALEA_MART": { + "group": 8, + "height": 4, + "map": 3, + "name": "AZALEA_MART", + "width": 6 + }, + "AZALEA_POKECENTER_1F": { + "group": 8, + "height": 4, + "map": 1, + "name": "AZALEA_POKECENTER_1F", + "width": 5 + }, + "AZALEA_TOWN": { + "group": 8, + "height": 9, + "map": 7, + "name": "AZALEA_TOWN", + "width": 20 + }, + "BATTLE_TOWER_1F": { + "group": 22, + "height": 5, + "map": 11, + "name": "BATTLE_TOWER_1F", + "width": 8 + }, + "BATTLE_TOWER_BATTLE_ROOM": { + "group": 22, + "height": 4, + "map": 12, + "name": "BATTLE_TOWER_BATTLE_ROOM", + "width": 4 + }, + "BATTLE_TOWER_ELEVATOR": { + "group": 22, + "height": 2, + "map": 13, + "name": "BATTLE_TOWER_ELEVATOR", + "width": 2 + }, + "BATTLE_TOWER_HALLWAY": { + "group": 22, + "height": 2, + "map": 14, + "name": "BATTLE_TOWER_HALLWAY", + "width": 11 + }, + "BATTLE_TOWER_OUTSIDE": { + "group": 22, + "height": 14, + "map": 16, + "name": "BATTLE_TOWER_OUTSIDE", + "width": 10 + }, + "BILLS_FAMILYS_HOUSE": { + "group": 11, + "height": 4, + "map": 6, + "name": "BILLS_FAMILYS_HOUSE", + "width": 4 + }, + "BILLS_HOUSE": { + "group": 7, + "height": 4, + "map": 11, + "name": "BILLS_HOUSE", + "width": 4 + }, + "BILLS_OLDER_SISTERS_HOUSE": { + "group": 17, + "height": 4, + "map": 9, + "name": "BILLS_OLDER_SISTERS_HOUSE", + "width": 4 + }, + "BLACKTHORN_CITY": { + "group": 5, + "height": 18, + "map": 10, + "name": "BLACKTHORN_CITY", + "width": 20 + }, + "BLACKTHORN_DRAGON_SPEECH_HOUSE": { + "group": 5, + "height": 4, + "map": 3, + "name": "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "width": 4 + }, + "BLACKTHORN_EMYS_HOUSE": { + "group": 5, + "height": 4, + "map": 4, + "name": "BLACKTHORN_EMYS_HOUSE", + "width": 4 + }, + "BLACKTHORN_GYM_1F": { + "group": 5, + "height": 9, + "map": 1, + "name": "BLACKTHORN_GYM_1F", + "width": 5 + }, + "BLACKTHORN_GYM_2F": { + "group": 5, + "height": 9, + "map": 2, + "name": "BLACKTHORN_GYM_2F", + "width": 5 + }, + "BLACKTHORN_MART": { + "group": 5, + "height": 4, + "map": 5, + "name": "BLACKTHORN_MART", + "width": 6 + }, + "BLACKTHORN_POKECENTER_1F": { + "group": 5, + "height": 4, + "map": 6, + "name": "BLACKTHORN_POKECENTER_1F", + "width": 5 + }, + "BLUES_HOUSE": { + "group": 13, + "height": 4, + "map": 5, + "name": "BLUES_HOUSE", + "width": 4 + }, + "BRUNOS_ROOM": { + "group": 16, + "height": 9, + "map": 5, + "name": "BRUNOS_ROOM", + "width": 5 + }, + "BURNED_TOWER_1F": { + "group": 3, + "height": 9, + "map": 13, + "name": "BURNED_TOWER_1F", + "width": 10 + }, + "BURNED_TOWER_B1F": { + "group": 3, + "height": 9, + "map": 14, + "name": "BURNED_TOWER_B1F", + "width": 10 + }, + "CELADON_CAFE": { + "group": 21, + "height": 4, + "map": 22, + "name": "CELADON_CAFE", + "width": 6 + }, + "CELADON_CITY": { + "group": 21, + "height": 18, + "map": 4, + "name": "CELADON_CITY", + "width": 20 + }, + "CELADON_DEPT_STORE_1F": { + "group": 21, + "height": 4, + "map": 5, + "name": "CELADON_DEPT_STORE_1F", + "width": 8 + }, + "CELADON_DEPT_STORE_2F": { + "group": 21, + "height": 4, + "map": 6, + "name": "CELADON_DEPT_STORE_2F", + "width": 8 + }, + "CELADON_DEPT_STORE_3F": { + "group": 21, + "height": 4, + "map": 7, + "name": "CELADON_DEPT_STORE_3F", + "width": 8 + }, + "CELADON_DEPT_STORE_4F": { + "group": 21, + "height": 4, + "map": 8, + "name": "CELADON_DEPT_STORE_4F", + "width": 8 + }, + "CELADON_DEPT_STORE_5F": { + "group": 21, + "height": 4, + "map": 9, + "name": "CELADON_DEPT_STORE_5F", + "width": 8 + }, + "CELADON_DEPT_STORE_6F": { + "group": 21, + "height": 4, + "map": 10, + "name": "CELADON_DEPT_STORE_6F", + "width": 8 + }, + "CELADON_DEPT_STORE_ELEVATOR": { + "group": 21, + "height": 2, + "map": 11, + "name": "CELADON_DEPT_STORE_ELEVATOR", + "width": 2 + }, + "CELADON_GAME_CORNER": { + "group": 21, + "height": 7, + "map": 19, + "name": "CELADON_GAME_CORNER", + "width": 10 + }, + "CELADON_GAME_CORNER_PRIZE_ROOM": { + "group": 21, + "height": 3, + "map": 20, + "name": "CELADON_GAME_CORNER_PRIZE_ROOM", + "width": 3 + }, + "CELADON_GYM": { + "group": 21, + "height": 9, + "map": 21, + "name": "CELADON_GYM", + "width": 5 + }, + "CELADON_MANSION_1F": { + "group": 21, + "height": 5, + "map": 12, + "name": "CELADON_MANSION_1F", + "width": 4 + }, + "CELADON_MANSION_2F": { + "group": 21, + "height": 5, + "map": 13, + "name": "CELADON_MANSION_2F", + "width": 4 + }, + "CELADON_MANSION_3F": { + "group": 21, + "height": 5, + "map": 14, + "name": "CELADON_MANSION_3F", + "width": 4 + }, + "CELADON_MANSION_ROOF": { + "group": 21, + "height": 5, + "map": 15, + "name": "CELADON_MANSION_ROOF", + "width": 4 + }, + "CELADON_MANSION_ROOF_HOUSE": { + "group": 21, + "height": 4, + "map": 16, + "name": "CELADON_MANSION_ROOF_HOUSE", + "width": 4 + }, + "CELADON_POKECENTER_1F": { + "group": 21, + "height": 4, + "map": 17, + "name": "CELADON_POKECENTER_1F", + "width": 5 + }, + "CELADON_POKECENTER_2F_BETA": { + "group": 21, + "height": 4, + "map": 18, + "name": "CELADON_POKECENTER_2F_BETA", + "width": 8 + }, + "CERULEAN_CITY": { + "group": 7, + "height": 18, + "map": 17, + "name": "CERULEAN_CITY", + "width": 20 + }, + "CERULEAN_GYM": { + "group": 7, + "height": 8, + "map": 6, + "name": "CERULEAN_GYM", + "width": 5 + }, + "CERULEAN_GYM_BADGE_SPEECH_HOUSE": { + "group": 7, + "height": 4, + "map": 1, + "name": "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "width": 4 + }, + "CERULEAN_MART": { + "group": 7, + "height": 4, + "map": 7, + "name": "CERULEAN_MART", + "width": 6 + }, + "CERULEAN_POKECENTER_1F": { + "group": 7, + "height": 4, + "map": 4, + "name": "CERULEAN_POKECENTER_1F", + "width": 5 + }, + "CERULEAN_POKECENTER_2F_BETA": { + "group": 7, + "height": 4, + "map": 5, + "name": "CERULEAN_POKECENTER_2F_BETA", + "width": 8 + }, + "CERULEAN_POLICE_STATION": { + "group": 7, + "height": 4, + "map": 2, + "name": "CERULEAN_POLICE_STATION", + "width": 4 + }, + "CERULEAN_TRADE_SPEECH_HOUSE": { + "group": 7, + "height": 4, + "map": 3, + "name": "CERULEAN_TRADE_SPEECH_HOUSE", + "width": 4 + }, + "CHARCOAL_KILN": { + "group": 8, + "height": 4, + "map": 2, + "name": "CHARCOAL_KILN", + "width": 4 + }, + "CHERRYGROVE_CITY": { + "group": 26, + "height": 9, + "map": 3, + "name": "CHERRYGROVE_CITY", + "width": 20 + }, + "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE": { + "group": 26, + "height": 4, + "map": 8, + "name": "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "width": 4 + }, + "CHERRYGROVE_GYM_SPEECH_HOUSE": { + "group": 26, + "height": 4, + "map": 6, + "name": "CHERRYGROVE_GYM_SPEECH_HOUSE", + "width": 4 + }, + "CHERRYGROVE_MART": { + "group": 26, + "height": 4, + "map": 4, + "name": "CHERRYGROVE_MART", + "width": 6 + }, + "CHERRYGROVE_POKECENTER_1F": { + "group": 26, + "height": 4, + "map": 5, + "name": "CHERRYGROVE_POKECENTER_1F", + "width": 5 + }, + "CIANWOOD_CITY": { + "group": 22, + "height": 27, + "map": 3, + "name": "CIANWOOD_CITY", + "width": 15 + }, + "CIANWOOD_GYM": { + "group": 22, + "height": 9, + "map": 5, + "name": "CIANWOOD_GYM", + "width": 5 + }, + "CIANWOOD_LUGIA_SPEECH_HOUSE": { + "group": 22, + "height": 4, + "map": 9, + "name": "CIANWOOD_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + "CIANWOOD_PHARMACY": { + "group": 22, + "height": 4, + "map": 7, + "name": "CIANWOOD_PHARMACY", + "width": 4 + }, + "CIANWOOD_PHOTO_STUDIO": { + "group": 22, + "height": 4, + "map": 8, + "name": "CIANWOOD_PHOTO_STUDIO", + "width": 4 + }, + "CIANWOOD_POKECENTER_1F": { + "group": 22, + "height": 4, + "map": 6, + "name": "CIANWOOD_POKECENTER_1F", + "width": 5 + }, + "CINNABAR_ISLAND": { + "group": 6, + "height": 9, + "map": 8, + "name": "CINNABAR_ISLAND", + "width": 10 + }, + "CINNABAR_POKECENTER_1F": { + "group": 6, + "height": 4, + "map": 1, + "name": "CINNABAR_POKECENTER_1F", + "width": 5 + }, + "CINNABAR_POKECENTER_2F_BETA": { + "group": 6, + "height": 4, + "map": 2, + "name": "CINNABAR_POKECENTER_2F_BETA", + "width": 8 + }, + "COLOSSEUM": { + "group": 20, + "height": 4, + "map": 3, + "name": "COLOSSEUM", + "width": 5 + }, + "COPYCATS_HOUSE_1F": { + "group": 25, + "height": 4, + "map": 11, + "name": "COPYCATS_HOUSE_1F", + "width": 4 + }, + "COPYCATS_HOUSE_2F": { + "group": 25, + "height": 3, + "map": 12, + "name": "COPYCATS_HOUSE_2F", + "width": 5 + }, + "DANCE_THEATER": { + "group": 4, + "height": 7, + "map": 5, + "name": "DANCE_THEATER", + "width": 6 + }, + "DARK_CAVE_BLACKTHORN_ENTRANCE": { + "group": 3, + "height": 18, + "map": 79, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + "DARK_CAVE_VIOLET_ENTRANCE": { + "group": 3, + "height": 18, + "map": 78, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + "DAY_CARE": { + "group": 11, + "height": 4, + "map": 24, + "name": "DAY_CARE", + "width": 5 + }, + "DAY_OF_WEEK_SIBLINGS_HOUSE": { + "group": 24, + "height": 4, + "map": 11, + "name": "DAY_OF_WEEK_SIBLINGS_HOUSE", + "width": 4 + }, + "DIGLETTS_CAVE": { + "group": 3, + "height": 18, + "map": 84, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + "DRAGONS_DEN_1F": { + "group": 3, + "height": 9, + "map": 80, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + "DRAGONS_DEN_B1F": { + "group": 3, + "height": 18, + "map": 81, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + "DRAGON_SHRINE": { + "group": 3, + "height": 5, + "map": 82, + "name": "DRAGON_SHRINE", + "width": 5 + }, + "EARLS_POKEMON_ACADEMY": { + "group": 10, + "height": 8, + "map": 8, + "name": "EARLS_POKEMON_ACADEMY", + "width": 4 + }, + "ECRUTEAK_CITY": { + "group": 4, + "height": 18, + "map": 9, + "name": "ECRUTEAK_CITY", + "width": 20 + }, + "ECRUTEAK_GYM": { + "group": 4, + "height": 9, + "map": 7, + "name": "ECRUTEAK_GYM", + "width": 5 + }, + "ECRUTEAK_ITEMFINDER_HOUSE": { + "group": 4, + "height": 4, + "map": 8, + "name": "ECRUTEAK_ITEMFINDER_HOUSE", + "width": 4 + }, + "ECRUTEAK_LUGIA_SPEECH_HOUSE": { + "group": 4, + "height": 4, + "map": 4, + "name": "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + "ECRUTEAK_MART": { + "group": 4, + "height": 4, + "map": 6, + "name": "ECRUTEAK_MART", + "width": 6 + }, + "ECRUTEAK_POKECENTER_1F": { + "group": 4, + "height": 4, + "map": 3, + "name": "ECRUTEAK_POKECENTER_1F", + "width": 5 + }, + "ECRUTEAK_TIN_TOWER_ENTRANCE": { + "group": 4, + "height": 9, + "map": 1, + "name": "ECRUTEAK_TIN_TOWER_ENTRANCE", + "width": 10 + }, + "ELMS_HOUSE": { + "group": 24, + "height": 4, + "map": 9, + "name": "ELMS_HOUSE", + "width": 4 + }, + "ELMS_LAB": { + "group": 24, + "height": 6, + "map": 5, + "name": "ELMS_LAB", + "width": 5 + }, + "FAST_SHIP_1F": { + "group": 15, + "height": 9, + "map": 3, + "name": "FAST_SHIP_1F", + "width": 16 + }, + "FAST_SHIP_B1F": { + "group": 15, + "height": 8, + "map": 7, + "name": "FAST_SHIP_B1F", + "width": 16 + }, + "FAST_SHIP_CABINS_NNW_NNE_NE": { + "group": 15, + "height": 16, + "map": 4, + "name": "FAST_SHIP_CABINS_NNW_NNE_NE", + "width": 4 + }, + "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN": { + "group": 15, + "height": 17, + "map": 6, + "name": "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "width": 5 + }, + "FAST_SHIP_CABINS_SW_SSW_NW": { + "group": 15, + "height": 16, + "map": 5, + "name": "FAST_SHIP_CABINS_SW_SSW_NW", + "width": 4 + }, + "FIGHTING_DOJO": { + "group": 25, + "height": 6, + "map": 3, + "name": "FIGHTING_DOJO", + "width": 5 + }, + "FUCHSIA_CITY": { + "group": 17, + "height": 18, + "map": 5, + "name": "FUCHSIA_CITY", + "width": 20 + }, + "FUCHSIA_GYM": { + "group": 17, + "height": 9, + "map": 8, + "name": "FUCHSIA_GYM", + "width": 5 + }, + "FUCHSIA_MART": { + "group": 17, + "height": 4, + "map": 6, + "name": "FUCHSIA_MART", + "width": 6 + }, + "FUCHSIA_POKECENTER_1F": { + "group": 17, + "height": 4, + "map": 10, + "name": "FUCHSIA_POKECENTER_1F", + "width": 5 + }, + "FUCHSIA_POKECENTER_2F_BETA": { + "group": 17, + "height": 4, + "map": 11, + "name": "FUCHSIA_POKECENTER_2F_BETA", + "width": 8 + }, + "GOLDENROD_BIKE_SHOP": { + "group": 11, + "height": 4, + "map": 4, + "name": "GOLDENROD_BIKE_SHOP", + "width": 4 + }, + "GOLDENROD_CITY": { + "group": 11, + "height": 18, + "map": 2, + "name": "GOLDENROD_CITY", + "width": 20 + }, + "GOLDENROD_DEPT_STORE_1F": { + "group": 11, + "height": 4, + "map": 11, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_2F": { + "group": 11, + "height": 4, + "map": 12, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_3F": { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_4F": { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_5F": { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_6F": { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_B1F": { + "group": 3, + "height": 9, + "map": 55, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + "GOLDENROD_DEPT_STORE_ELEVATOR": { + "group": 11, + "height": 2, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + "GOLDENROD_DEPT_STORE_ROOF": { + "group": 11, + "height": 4, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ROOF", + "width": 8 + }, + "GOLDENROD_FLOWER_SHOP": { + "group": 11, + "height": 4, + "map": 8, + "name": "GOLDENROD_FLOWER_SHOP", + "width": 4 + }, + "GOLDENROD_GAME_CORNER": { + "group": 11, + "height": 7, + "map": 19, + "name": "GOLDENROD_GAME_CORNER", + "width": 10 + }, + "GOLDENROD_GYM": { + "group": 11, + "height": 9, + "map": 3, + "name": "GOLDENROD_GYM", + "width": 10 + }, + "GOLDENROD_HAPPINESS_RATER": { + "group": 11, + "height": 4, + "map": 5, + "name": "GOLDENROD_HAPPINESS_RATER", + "width": 4 + }, + "GOLDENROD_MAGNET_TRAIN_STATION": { + "group": 11, + "height": 9, + "map": 7, + "name": "GOLDENROD_MAGNET_TRAIN_STATION", + "width": 10 + }, + "GOLDENROD_NAME_RATER": { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + "GOLDENROD_POKECENTER_1F": { + "group": 11, + "height": 4, + "map": 20, + "name": "GOLDENROD_POKECENTER_1F", + "width": 5 + }, + "GOLDENROD_PP_SPEECH_HOUSE": { + "group": 11, + "height": 4, + "map": 9, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + "GOLDENROD_UNDERGROUND": { + "group": 3, + "height": 18, + "map": 53, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES": { + "group": 3, + "height": 18, + "map": 54, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_WAREHOUSE": { + "group": 3, + "height": 9, + "map": 56, + "name": "GOLDENROD_UNDERGROUND_WAREHOUSE", + "width": 10 + }, + "GUIDE_GENTS_HOUSE": { + "group": 26, + "height": 4, + "map": 7, + "name": "GUIDE_GENTS_HOUSE", + "width": 4 + }, + "HALL_OF_FAME": { + "group": 16, + "height": 7, + "map": 8, + "name": "HALL_OF_FAME", + "width": 5 + }, + "ICE_PATH_1F": { + "group": 3, + "height": 18, + "map": 61, + "name": "ICE_PATH_1F", + "width": 20 + }, + "ICE_PATH_B1F": { + "group": 3, + "height": 18, + "map": 62, + "name": "ICE_PATH_B1F", + "width": 10 + }, + "ICE_PATH_B2F_BLACKTHORN_SIDE": { + "group": 3, + "height": 9, + "map": 64, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + "ICE_PATH_B2F_MAHOGANY_SIDE": { + "group": 3, + "height": 9, + "map": 63, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + "ICE_PATH_B3F": { + "group": 3, + "height": 9, + "map": 65, + "name": "ICE_PATH_B3F", + "width": 10 + }, + "ILEX_FOREST": { + "group": 3, + "height": 27, + "map": 52, + "name": "ILEX_FOREST", + "width": 15 + }, + "ILEX_FOREST_AZALEA_GATE": { + "group": 11, + "height": 4, + "map": 22, + "name": "ILEX_FOREST_AZALEA_GATE", + "width": 5 + }, + "INDIGO_PLATEAU_POKECENTER_1F": { + "group": 16, + "height": 7, + "map": 2, + "name": "INDIGO_PLATEAU_POKECENTER_1F", + "width": 9 + }, + "KARENS_ROOM": { + "group": 16, + "height": 9, + "map": 6, + "name": "KARENS_ROOM", + "width": 5 + }, + "KOGAS_ROOM": { + "group": 16, + "height": 9, + "map": 4, + "name": "KOGAS_ROOM", + "width": 5 + }, + "KURTS_HOUSE": { + "group": 8, + "height": 4, + "map": 4, + "name": "KURTS_HOUSE", + "width": 8 + }, + "LAKE_OF_RAGE": { + "group": 9, + "height": 18, + "map": 6, + "name": "LAKE_OF_RAGE", + "width": 20 + }, + "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE": { + "group": 9, + "height": 4, + "map": 1, + "name": "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "width": 4 + }, + "LAKE_OF_RAGE_MAGIKARP_HOUSE": { + "group": 9, + "height": 4, + "map": 2, + "name": "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "width": 4 + }, + "LANCES_ROOM": { + "group": 16, + "height": 12, + "map": 7, + "name": "LANCES_ROOM", + "width": 5 + }, + "LAVENDER_MART": { + "group": 18, + "height": 4, + "map": 10, + "name": "LAVENDER_MART", + "width": 6 + }, + "LAVENDER_NAME_RATER": { + "group": 18, + "height": 4, + "map": 9, + "name": "LAVENDER_NAME_RATER", + "width": 4 + }, + "LAVENDER_POKECENTER_1F": { + "group": 18, + "height": 4, + "map": 5, + "name": "LAVENDER_POKECENTER_1F", + "width": 5 + }, + "LAVENDER_POKECENTER_2F_BETA": { + "group": 18, + "height": 4, + "map": 6, + "name": "LAVENDER_POKECENTER_2F_BETA", + "width": 8 + }, + "LAVENDER_SPEECH_HOUSE": { + "group": 18, + "height": 4, + "map": 8, + "name": "LAVENDER_SPEECH_HOUSE", + "width": 4 + }, + "LAVENDER_TOWN": { + "group": 18, + "height": 9, + "map": 4, + "name": "LAVENDER_TOWN", + "width": 10 + }, + "LAV_RADIO_TOWER_1F": { + "group": 18, + "height": 4, + "map": 12, + "name": "LAV_RADIO_TOWER_1F", + "width": 10 + }, + "MAHOGANY_GYM": { + "group": 2, + "height": 9, + "map": 2, + "name": "MAHOGANY_GYM", + "width": 5 + }, + "MAHOGANY_MART_1F": { + "group": 3, + "height": 4, + "map": 48, + "name": "MAHOGANY_MART_1F", + "width": 4 + }, + "MAHOGANY_POKECENTER_1F": { + "group": 2, + "height": 4, + "map": 3, + "name": "MAHOGANY_POKECENTER_1F", + "width": 5 + }, + "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE": { + "group": 2, + "height": 4, + "map": 1, + "name": "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "width": 4 + }, + "MAHOGANY_TOWN": { + "group": 2, + "height": 9, + "map": 7, + "name": "MAHOGANY_TOWN", + "width": 10 + }, + "MANIAS_HOUSE": { + "group": 22, + "height": 4, + "map": 4, + "name": "MANIAS_HOUSE", + "width": 4 + }, + "MOBILE_BATTLE_ROOM": { + "group": 20, + "height": 4, + "map": 6, + "name": "MOBILE_BATTLE_ROOM", + "width": 5 + }, + "MOBILE_TRADE_ROOM": { + "group": 20, + "height": 4, + "map": 5, + "name": "MOBILE_TRADE_ROOM", + "width": 5 + }, + "MOUNT_MOON": { + "group": 3, + "height": 9, + "map": 85, + "name": "MOUNT_MOON", + "width": 15 + }, + "MOUNT_MOON_GIFT_SHOP": { + "group": 15, + "height": 4, + "map": 11, + "name": "MOUNT_MOON_GIFT_SHOP", + "width": 4 + }, + "MOUNT_MOON_SQUARE": { + "group": 15, + "height": 9, + "map": 10, + "name": "MOUNT_MOON_SQUARE", + "width": 15 + }, + "MOUNT_MORTAR_1F_INSIDE": { + "group": 3, + "height": 27, + "map": 58, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_1F_OUTSIDE": { + "group": 3, + "height": 18, + "map": 57, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + "MOUNT_MORTAR_2F_INSIDE": { + "group": 3, + "height": 18, + "map": 59, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_B1F": { + "group": 3, + "height": 18, + "map": 60, + "name": "MOUNT_MORTAR_B1F", + "width": 20 + }, + "MOVE_DELETERS_HOUSE": { + "group": 5, + "height": 4, + "map": 7, + "name": "MOVE_DELETERS_HOUSE", + "width": 4 + }, + "MR_FUJIS_HOUSE": { + "group": 18, + "height": 4, + "map": 7, + "name": "MR_FUJIS_HOUSE", + "width": 5 + }, + "MR_POKEMONS_HOUSE": { + "group": 26, + "height": 4, + "map": 10, + "name": "MR_POKEMONS_HOUSE", + "width": 4 + }, + "MR_PSYCHICS_HOUSE": { + "group": 25, + "height": 4, + "map": 8, + "name": "MR_PSYCHICS_HOUSE", + "width": 4 + }, + "NATIONAL_PARK": { + "group": 3, + "height": 27, + "map": 15, + "name": "NATIONAL_PARK", + "width": 20 + }, + "NATIONAL_PARK_BUG_CONTEST": { + "group": 3, + "height": 27, + "map": 16, + "name": "NATIONAL_PARK_BUG_CONTEST", + "width": 20 + }, + "NEW_BARK_TOWN": { + "group": 24, + "height": 9, + "map": 4, + "name": "NEW_BARK_TOWN", + "width": 10 + }, + "OAKS_LAB": { + "group": 13, + "height": 6, + "map": 6, + "name": "OAKS_LAB", + "width": 5 + }, + "OLIVINE_CAFE": { + "group": 1, + "height": 4, + "map": 7, + "name": "OLIVINE_CAFE", + "width": 4 + }, + "OLIVINE_CITY": { + "group": 1, + "height": 18, + "map": 14, + "name": "OLIVINE_CITY", + "width": 20 + }, + "OLIVINE_GOOD_ROD_HOUSE": { + "group": 1, + "height": 4, + "map": 6, + "name": "OLIVINE_GOOD_ROD_HOUSE", + "width": 4 + }, + "OLIVINE_GYM": { + "group": 1, + "height": 8, + "map": 2, + "name": "OLIVINE_GYM", + "width": 5 + }, + "OLIVINE_HOUSE_BETA": { + "group": 1, + "height": 4, + "map": 4, + "name": "OLIVINE_HOUSE_BETA", + "width": 4 + }, + "OLIVINE_LIGHTHOUSE_1F": { + "group": 3, + "height": 9, + "map": 42, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_2F": { + "group": 3, + "height": 9, + "map": 43, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_3F": { + "group": 3, + "height": 9, + "map": 44, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_4F": { + "group": 3, + "height": 9, + "map": 45, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_5F": { + "group": 3, + "height": 9, + "map": 46, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_6F": { + "group": 3, + "height": 9, + "map": 47, + "name": "OLIVINE_LIGHTHOUSE_6F", + "width": 10 + }, + "OLIVINE_MART": { + "group": 1, + "height": 4, + "map": 8, + "name": "OLIVINE_MART", + "width": 6 + }, + "OLIVINE_POKECENTER_1F": { + "group": 1, + "height": 4, + "map": 1, + "name": "OLIVINE_POKECENTER_1F", + "width": 5 + }, + "OLIVINE_PORT": { + "group": 15, + "height": 18, + "map": 1, + "name": "OLIVINE_PORT", + "width": 10 + }, + "OLIVINE_PORT_PASSAGE": { + "group": 15, + "height": 9, + "map": 8, + "name": "OLIVINE_PORT_PASSAGE", + "width": 10 + }, + "OLIVINE_PUNISHMENT_SPEECH_HOUSE": { + "group": 1, + "height": 4, + "map": 5, + "name": "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "width": 4 + }, + "OLIVINE_TIMS_HOUSE": { + "group": 1, + "height": 4, + "map": 3, + "name": "OLIVINE_TIMS_HOUSE", + "width": 4 + }, + "PALLET_TOWN": { + "group": 13, + "height": 9, + "map": 2, + "name": "PALLET_TOWN", + "width": 10 + }, + "PEWTER_CITY": { + "group": 14, + "height": 18, + "map": 2, + "name": "PEWTER_CITY", + "width": 20 + }, + "PEWTER_GYM": { + "group": 14, + "height": 7, + "map": 4, + "name": "PEWTER_GYM", + "width": 5 + }, + "PEWTER_MART": { + "group": 14, + "height": 4, + "map": 5, + "name": "PEWTER_MART", + "width": 6 + }, + "PEWTER_NIDORAN_SPEECH_HOUSE": { + "group": 14, + "height": 4, + "map": 3, + "name": "PEWTER_NIDORAN_SPEECH_HOUSE", + "width": 4 + }, + "PEWTER_POKECENTER_1F": { + "group": 14, + "height": 4, + "map": 6, + "name": "PEWTER_POKECENTER_1F", + "width": 5 + }, + "PEWTER_POKECENTER_2F_BETA": { + "group": 14, + "height": 4, + "map": 7, + "name": "PEWTER_POKECENTER_2F_BETA", + "width": 8 + }, + "PEWTER_SNOOZE_SPEECH_HOUSE": { + "group": 14, + "height": 4, + "map": 8, + "name": "PEWTER_SNOOZE_SPEECH_HOUSE", + "width": 4 + }, + "PLAYERS_HOUSE_1F": { + "group": 24, + "height": 4, + "map": 6, + "name": "PLAYERS_HOUSE_1F", + "width": 5 + }, + "PLAYERS_HOUSE_2F": { + "group": 24, + "height": 3, + "map": 7, + "name": "PLAYERS_HOUSE_2F", + "width": 4 + }, + "PLAYERS_NEIGHBORS_HOUSE": { + "group": 24, + "height": 4, + "map": 8, + "name": "PLAYERS_NEIGHBORS_HOUSE", + "width": 4 + }, + "POKECENTER_2F": { + "group": 20, + "height": 4, + "map": 1, + "name": "POKECENTER_2F", + "width": 8 + }, + "POKECOM_CENTER_ADMIN_OFFICE_MOBILE": { + "group": 11, + "height": 16, + "map": 21, + "name": "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "width": 16 + }, + "POKEMON_FAN_CLUB": { + "group": 12, + "height": 4, + "map": 7, + "name": "POKEMON_FAN_CLUB", + "width": 5 + }, + "POKE_SEERS_HOUSE": { + "group": 22, + "height": 4, + "map": 10, + "name": "POKE_SEERS_HOUSE", + "width": 4 + }, + "POWER_PLANT": { + "group": 7, + "height": 9, + "map": 10, + "name": "POWER_PLANT", + "width": 10 + }, + "RADIO_TOWER_1F": { + "group": 3, + "height": 4, + "map": 17, + "name": "RADIO_TOWER_1F", + "width": 9 + }, + "RADIO_TOWER_2F": { + "group": 3, + "height": 4, + "map": 18, + "name": "RADIO_TOWER_2F", + "width": 9 + }, + "RADIO_TOWER_3F": { + "group": 3, + "height": 4, + "map": 19, + "name": "RADIO_TOWER_3F", + "width": 9 + }, + "RADIO_TOWER_4F": { + "group": 3, + "height": 4, + "map": 20, + "name": "RADIO_TOWER_4F", + "width": 9 + }, + "RADIO_TOWER_5F": { + "group": 3, + "height": 4, + "map": 21, + "name": "RADIO_TOWER_5F", + "width": 9 + }, + "REDS_HOUSE_1F": { + "group": 13, + "height": 4, + "map": 3, + "name": "REDS_HOUSE_1F", + "width": 4 + }, + "REDS_HOUSE_2F": { + "group": 13, + "height": 4, + "map": 4, + "name": "REDS_HOUSE_2F", + "width": 4 + }, + "ROCK_TUNNEL_1F": { + "group": 3, + "height": 18, + "map": 87, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + "ROCK_TUNNEL_B1F": { + "group": 3, + "height": 18, + "map": 88, + "name": "ROCK_TUNNEL_B1F", + "width": 15 + }, + "ROUTE_1": { + "group": 13, + "height": 18, + "map": 1, + "name": "ROUTE_1", + "width": 10 + }, + "ROUTE_10_NORTH": { + "group": 7, + "height": 9, + "map": 14, + "name": "ROUTE_10_NORTH", + "width": 10 + }, + "ROUTE_10_POKECENTER_1F": { + "group": 7, + "height": 4, + "map": 8, + "name": "ROUTE_10_POKECENTER_1F", + "width": 5 + }, + "ROUTE_10_POKECENTER_2F_BETA": { + "group": 7, + "height": 4, + "map": 9, + "name": "ROUTE_10_POKECENTER_2F_BETA", + "width": 8 + }, + "ROUTE_10_SOUTH": { + "group": 18, + "height": 9, + "map": 3, + "name": "ROUTE_10_SOUTH", + "width": 10 + }, + "ROUTE_11": { + "group": 12, + "height": 9, + "map": 2, + "name": "ROUTE_11", + "width": 20 + }, + "ROUTE_12": { + "group": 18, + "height": 27, + "map": 2, + "name": "ROUTE_12", + "width": 10 + }, + "ROUTE_12_SUPER_ROD_HOUSE": { + "group": 18, + "height": 4, + "map": 14, + "name": "ROUTE_12_SUPER_ROD_HOUSE", + "width": 4 + }, + "ROUTE_13": { + "group": 17, + "height": 9, + "map": 1, + "name": "ROUTE_13", + "width": 30 + }, + "ROUTE_14": { + "group": 17, + "height": 18, + "map": 2, + "name": "ROUTE_14", + "width": 10 + }, + "ROUTE_15": { + "group": 17, + "height": 9, + "map": 3, + "name": "ROUTE_15", + "width": 20 + }, + "ROUTE_15_FUCHSIA_GATE": { + "group": 17, + "height": 4, + "map": 13, + "name": "ROUTE_15_FUCHSIA_GATE", + "width": 5 + }, + "ROUTE_16": { + "group": 21, + "height": 9, + "map": 2, + "name": "ROUTE_16", + "width": 10 + }, + "ROUTE_16_FUCHSIA_SPEECH_HOUSE": { + "group": 21, + "height": 4, + "map": 23, + "name": "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "width": 4 + }, + "ROUTE_16_GATE": { + "group": 21, + "height": 4, + "map": 24, + "name": "ROUTE_16_GATE", + "width": 5 + }, + "ROUTE_17": { + "group": 21, + "height": 45, + "map": 3, + "name": "ROUTE_17", + "width": 10 + }, + "ROUTE_17_ROUTE_18_GATE": { + "group": 21, + "height": 4, + "map": 26, + "name": "ROUTE_17_ROUTE_18_GATE", + "width": 5 + }, + "ROUTE_18": { + "group": 17, + "height": 9, + "map": 4, + "name": "ROUTE_18", + "width": 10 + }, + "ROUTE_19": { + "group": 6, + "height": 18, + "map": 5, + "name": "ROUTE_19", + "width": 10 + }, + "ROUTE_19_FUCHSIA_GATE": { + "group": 6, + "height": 4, + "map": 3, + "name": "ROUTE_19_FUCHSIA_GATE", + "width": 5 + }, + "ROUTE_2": { + "group": 23, + "height": 27, + "map": 1, + "name": "ROUTE_2", + "width": 10 + }, + "ROUTE_20": { + "group": 6, + "height": 9, + "map": 6, + "name": "ROUTE_20", + "width": 30 + }, + "ROUTE_21": { + "group": 6, + "height": 18, + "map": 7, + "name": "ROUTE_21", + "width": 10 + }, + "ROUTE_22": { + "group": 23, + "height": 9, + "map": 2, + "name": "ROUTE_22", + "width": 20 + }, + "ROUTE_23": { + "group": 16, + "height": 9, + "map": 1, + "name": "ROUTE_23", + "width": 10 + }, + "ROUTE_24": { + "group": 7, + "height": 9, + "map": 15, + "name": "ROUTE_24", + "width": 10 + }, + "ROUTE_25": { + "group": 7, + "height": 9, + "map": 16, + "name": "ROUTE_25", + "width": 30 + }, + "ROUTE_26": { + "group": 24, + "height": 54, + "map": 1, + "name": "ROUTE_26", + "width": 10 + }, + "ROUTE_26_HEAL_HOUSE": { + "group": 24, + "height": 4, + "map": 10, + "name": "ROUTE_26_HEAL_HOUSE", + "width": 4 + }, + "ROUTE_27": { + "group": 24, + "height": 9, + "map": 2, + "name": "ROUTE_27", + "width": 40 + }, + "ROUTE_27_SANDSTORM_HOUSE": { + "group": 24, + "height": 4, + "map": 12, + "name": "ROUTE_27_SANDSTORM_HOUSE", + "width": 4 + }, + "ROUTE_28": { + "group": 19, + "height": 9, + "map": 1, + "name": "ROUTE_28", + "width": 20 + }, + "ROUTE_28_STEEL_WING_HOUSE": { + "group": 19, + "height": 4, + "map": 4, + "name": "ROUTE_28_STEEL_WING_HOUSE", + "width": 4 + }, + "ROUTE_29": { + "group": 24, + "height": 9, + "map": 3, + "name": "ROUTE_29", + "width": 30 + }, + "ROUTE_29_ROUTE_46_GATE": { + "group": 24, + "height": 4, + "map": 13, + "name": "ROUTE_29_ROUTE_46_GATE", + "width": 5 + }, + "ROUTE_2_GATE": { + "group": 23, + "height": 4, + "map": 12, + "name": "ROUTE_2_GATE", + "width": 5 + }, + "ROUTE_2_NUGGET_HOUSE": { + "group": 23, + "height": 4, + "map": 11, + "name": "ROUTE_2_NUGGET_HOUSE", + "width": 4 + }, + "ROUTE_3": { + "group": 14, + "height": 9, + "map": 1, + "name": "ROUTE_3", + "width": 30 + }, + "ROUTE_30": { + "group": 26, + "height": 27, + "map": 1, + "name": "ROUTE_30", + "width": 10 + }, + "ROUTE_30_BERRY_HOUSE": { + "group": 26, + "height": 4, + "map": 9, + "name": "ROUTE_30_BERRY_HOUSE", + "width": 4 + }, + "ROUTE_31": { + "group": 26, + "height": 9, + "map": 2, + "name": "ROUTE_31", + "width": 20 + }, + "ROUTE_31_VIOLET_GATE": { + "group": 26, + "height": 4, + "map": 11, + "name": "ROUTE_31_VIOLET_GATE", + "width": 5 + }, + "ROUTE_32": { + "group": 10, + "height": 45, + "map": 1, + "name": "ROUTE_32", + "width": 10 + }, + "ROUTE_32_POKECENTER_1F": { + "group": 10, + "height": 4, + "map": 13, + "name": "ROUTE_32_POKECENTER_1F", + "width": 5 + }, + "ROUTE_32_RUINS_OF_ALPH_GATE": { + "group": 10, + "height": 4, + "map": 12, + "name": "ROUTE_32_RUINS_OF_ALPH_GATE", + "width": 5 + }, + "ROUTE_33": { + "group": 8, + "height": 9, + "map": 6, + "name": "ROUTE_33", + "width": 10 + }, + "ROUTE_34": { + "group": 11, + "height": 27, + "map": 1, + "name": "ROUTE_34", + "width": 10 + }, + "ROUTE_34_ILEX_FOREST_GATE": { + "group": 11, + "height": 4, + "map": 23, + "name": "ROUTE_34_ILEX_FOREST_GATE", + "width": 5 + }, + "ROUTE_35": { + "group": 10, + "height": 18, + "map": 2, + "name": "ROUTE_35", + "width": 10 + }, + "ROUTE_35_GOLDENROD_GATE": { + "group": 10, + "height": 4, + "map": 14, + "name": "ROUTE_35_GOLDENROD_GATE", + "width": 5 + }, + "ROUTE_35_NATIONAL_PARK_GATE": { + "group": 10, + "height": 4, + "map": 15, + "name": "ROUTE_35_NATIONAL_PARK_GATE", + "width": 4 + }, + "ROUTE_36": { + "group": 10, + "height": 9, + "map": 3, + "name": "ROUTE_36", + "width": 30 + }, + "ROUTE_36_NATIONAL_PARK_GATE": { + "group": 10, + "height": 4, + "map": 17, + "name": "ROUTE_36_NATIONAL_PARK_GATE", + "width": 5 + }, + "ROUTE_36_RUINS_OF_ALPH_GATE": { + "group": 10, + "height": 4, + "map": 16, + "name": "ROUTE_36_RUINS_OF_ALPH_GATE", + "width": 5 + }, + "ROUTE_37": { + "group": 10, + "height": 9, + "map": 4, + "name": "ROUTE_37", + "width": 10 + }, + "ROUTE_38": { + "group": 1, + "height": 9, + "map": 12, + "name": "ROUTE_38", + "width": 20 + }, + "ROUTE_38_ECRUTEAK_GATE": { + "group": 1, + "height": 4, + "map": 9, + "name": "ROUTE_38_ECRUTEAK_GATE", + "width": 5 + }, + "ROUTE_39": { + "group": 1, + "height": 18, + "map": 13, + "name": "ROUTE_39", + "width": 10 + }, + "ROUTE_39_BARN": { + "group": 1, + "height": 4, + "map": 10, + "name": "ROUTE_39_BARN", + "width": 4 + }, + "ROUTE_39_FARMHOUSE": { + "group": 1, + "height": 4, + "map": 11, + "name": "ROUTE_39_FARMHOUSE", + "width": 4 + }, + "ROUTE_4": { + "group": 7, + "height": 9, + "map": 12, + "name": "ROUTE_4", + "width": 20 + }, + "ROUTE_40": { + "group": 22, + "height": 18, + "map": 1, + "name": "ROUTE_40", + "width": 10 + }, + "ROUTE_40_BATTLE_TOWER_GATE": { + "group": 22, + "height": 4, + "map": 15, + "name": "ROUTE_40_BATTLE_TOWER_GATE", + "width": 5 + }, + "ROUTE_41": { + "group": 22, + "height": 27, + "map": 2, + "name": "ROUTE_41", + "width": 25 + }, + "ROUTE_42": { + "group": 2, + "height": 9, + "map": 5, + "name": "ROUTE_42", + "width": 30 + }, + "ROUTE_42_ECRUTEAK_GATE": { + "group": 2, + "height": 4, + "map": 4, + "name": "ROUTE_42_ECRUTEAK_GATE", + "width": 5 + }, + "ROUTE_43": { + "group": 9, + "height": 27, + "map": 5, + "name": "ROUTE_43", + "width": 10 + }, + "ROUTE_43_GATE": { + "group": 9, + "height": 4, + "map": 4, + "name": "ROUTE_43_GATE", + "width": 5 + }, + "ROUTE_43_MAHOGANY_GATE": { + "group": 9, + "height": 4, + "map": 3, + "name": "ROUTE_43_MAHOGANY_GATE", + "width": 5 + }, + "ROUTE_44": { + "group": 2, + "height": 9, + "map": 6, + "name": "ROUTE_44", + "width": 30 + }, + "ROUTE_45": { + "group": 5, + "height": 45, + "map": 8, + "name": "ROUTE_45", + "width": 10 + }, + "ROUTE_46": { + "group": 5, + "height": 18, + "map": 9, + "name": "ROUTE_46", + "width": 10 + }, + "ROUTE_5": { + "group": 25, + "height": 9, + "map": 1, + "name": "ROUTE_5", + "width": 10 + }, + "ROUTE_5_CLEANSE_TAG_HOUSE": { + "group": 25, + "height": 4, + "map": 15, + "name": "ROUTE_5_CLEANSE_TAG_HOUSE", + "width": 4 + }, + "ROUTE_5_SAFFRON_GATE": { + "group": 25, + "height": 4, + "map": 14, + "name": "ROUTE_5_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_5_UNDERGROUND_PATH_ENTRANCE": { + "group": 25, + "height": 4, + "map": 13, + "name": "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + "ROUTE_6": { + "group": 12, + "height": 9, + "map": 1, + "name": "ROUTE_6", + "width": 10 + }, + "ROUTE_6_SAFFRON_GATE": { + "group": 12, + "height": 4, + "map": 12, + "name": "ROUTE_6_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_6_UNDERGROUND_PATH_ENTRANCE": { + "group": 12, + "height": 4, + "map": 13, + "name": "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + "ROUTE_7": { + "group": 21, + "height": 9, + "map": 1, + "name": "ROUTE_7", + "width": 10 + }, + "ROUTE_7_SAFFRON_GATE": { + "group": 21, + "height": 4, + "map": 25, + "name": "ROUTE_7_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_8": { + "group": 18, + "height": 9, + "map": 1, + "name": "ROUTE_8", + "width": 20 + }, + "ROUTE_8_SAFFRON_GATE": { + "group": 18, + "height": 4, + "map": 13, + "name": "ROUTE_8_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_9": { + "group": 7, + "height": 9, + "map": 13, + "name": "ROUTE_9", + "width": 30 + }, + "RUINS_OF_ALPH_AERODACTYL_CHAMBER": { + "group": 3, + "height": 5, + "map": 26, + "name": "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 32, + "name": "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM": { + "group": 3, + "height": 7, + "map": 36, + "name": "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_HO_OH_CHAMBER": { + "group": 3, + "height": 5, + "map": 23, + "name": "RUINS_OF_ALPH_HO_OH_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_HO_OH_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 29, + "name": "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_HO_OH_WORD_ROOM": { + "group": 3, + "height": 12, + "map": 33, + "name": "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_INNER_CHAMBER": { + "group": 3, + "height": 14, + "map": 27, + "name": "RUINS_OF_ALPH_INNER_CHAMBER", + "width": 10 + }, + "RUINS_OF_ALPH_KABUTO_CHAMBER": { + "group": 3, + "height": 5, + "map": 24, + "name": "RUINS_OF_ALPH_KABUTO_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_KABUTO_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 30, + "name": "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_KABUTO_WORD_ROOM": { + "group": 3, + "height": 7, + "map": 34, + "name": "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_OMANYTE_CHAMBER": { + "group": 3, + "height": 5, + "map": 25, + "name": "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 31, + "name": "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_OMANYTE_WORD_ROOM": { + "group": 3, + "height": 8, + "map": 35, + "name": "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_OUTSIDE": { + "group": 3, + "height": 18, + "map": 22, + "name": "RUINS_OF_ALPH_OUTSIDE", + "width": 10 + }, + "RUINS_OF_ALPH_RESEARCH_CENTER": { + "group": 3, + "height": 4, + "map": 28, + "name": "RUINS_OF_ALPH_RESEARCH_CENTER", + "width": 4 + }, + "SAFARI_ZONE_BETA": { + "group": 3, + "height": 18, + "map": 90, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + "SAFARI_ZONE_FUCHSIA_GATE_BETA": { + "group": 3, + "height": 4, + "map": 89, + "name": "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "width": 5 + }, + "SAFARI_ZONE_MAIN_OFFICE": { + "group": 17, + "height": 4, + "map": 7, + "name": "SAFARI_ZONE_MAIN_OFFICE", + "width": 4 + }, + "SAFARI_ZONE_WARDENS_HOME": { + "group": 17, + "height": 4, + "map": 12, + "name": "SAFARI_ZONE_WARDENS_HOME", + "width": 5 + }, + "SAFFRON_CITY": { + "group": 25, + "height": 18, + "map": 2, + "name": "SAFFRON_CITY", + "width": 20 + }, + "SAFFRON_GYM": { + "group": 25, + "height": 9, + "map": 4, + "name": "SAFFRON_GYM", + "width": 10 + }, + "SAFFRON_MAGNET_TRAIN_STATION": { + "group": 25, + "height": 9, + "map": 9, + "name": "SAFFRON_MAGNET_TRAIN_STATION", + "width": 10 + }, + "SAFFRON_MART": { + "group": 25, + "height": 4, + "map": 5, + "name": "SAFFRON_MART", + "width": 6 + }, + "SAFFRON_POKECENTER_1F": { + "group": 25, + "height": 4, + "map": 6, + "name": "SAFFRON_POKECENTER_1F", + "width": 5 + }, + "SAFFRON_POKECENTER_2F_BETA": { + "group": 25, + "height": 4, + "map": 7, + "name": "SAFFRON_POKECENTER_2F_BETA", + "width": 8 + }, + "SEAFOAM_GYM": { + "group": 6, + "height": 4, + "map": 4, + "name": "SEAFOAM_GYM", + "width": 5 + }, + "SILPH_CO_1F": { + "group": 25, + "height": 4, + "map": 10, + "name": "SILPH_CO_1F", + "width": 8 + }, + "SILVER_CAVE_ITEM_ROOMS": { + "group": 3, + "height": 9, + "map": 77, + "name": "SILVER_CAVE_ITEM_ROOMS", + "width": 10 + }, + "SILVER_CAVE_OUTSIDE": { + "group": 19, + "height": 18, + "map": 2, + "name": "SILVER_CAVE_OUTSIDE", + "width": 20 + }, + "SILVER_CAVE_POKECENTER_1F": { + "group": 19, + "height": 4, + "map": 3, + "name": "SILVER_CAVE_POKECENTER_1F", + "width": 5 + }, + "SILVER_CAVE_ROOM_1": { + "group": 3, + "height": 18, + "map": 74, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + "SILVER_CAVE_ROOM_2": { + "group": 3, + "height": 18, + "map": 75, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + "SILVER_CAVE_ROOM_3": { + "group": 3, + "height": 18, + "map": 76, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + "SLOWPOKE_WELL_B1F": { + "group": 3, + "height": 9, + "map": 40, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + "SLOWPOKE_WELL_B2F": { + "group": 3, + "height": 9, + "map": 41, + "name": "SLOWPOKE_WELL_B2F", + "width": 10 + }, + "SOUL_HOUSE": { + "group": 18, + "height": 4, + "map": 11, + "name": "SOUL_HOUSE", + "width": 5 + }, + "SPROUT_TOWER_1F": { + "group": 3, + "height": 8, + "map": 1, + "name": "SPROUT_TOWER_1F", + "width": 10 + }, + "SPROUT_TOWER_2F": { + "group": 3, + "height": 8, + "map": 2, + "name": "SPROUT_TOWER_2F", + "width": 10 + }, + "SPROUT_TOWER_3F": { + "group": 3, + "height": 8, + "map": 3, + "name": "SPROUT_TOWER_3F", + "width": 10 + }, + "TEAM_ROCKET_BASE_B1F": { + "group": 3, + "height": 9, + "map": 49, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B2F": { + "group": 3, + "height": 9, + "map": 50, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B3F": { + "group": 3, + "height": 9, + "map": 51, + "name": "TEAM_ROCKET_BASE_B3F", + "width": 15 + }, + "TIME_CAPSULE": { + "group": 20, + "height": 4, + "map": 4, + "name": "TIME_CAPSULE", + "width": 5 + }, + "TIN_TOWER_1F": { + "group": 3, + "height": 9, + "map": 4, + "name": "TIN_TOWER_1F", + "width": 10 + }, + "TIN_TOWER_2F": { + "group": 3, + "height": 9, + "map": 5, + "name": "TIN_TOWER_2F", + "width": 10 + }, + "TIN_TOWER_3F": { + "group": 3, + "height": 9, + "map": 6, + "name": "TIN_TOWER_3F", + "width": 10 + }, + "TIN_TOWER_4F": { + "group": 3, + "height": 9, + "map": 7, + "name": "TIN_TOWER_4F", + "width": 10 + }, + "TIN_TOWER_5F": { + "group": 3, + "height": 9, + "map": 8, + "name": "TIN_TOWER_5F", + "width": 10 + }, + "TIN_TOWER_6F": { + "group": 3, + "height": 9, + "map": 9, + "name": "TIN_TOWER_6F", + "width": 10 + }, + "TIN_TOWER_7F": { + "group": 3, + "height": 9, + "map": 10, + "name": "TIN_TOWER_7F", + "width": 10 + }, + "TIN_TOWER_8F": { + "group": 3, + "height": 9, + "map": 11, + "name": "TIN_TOWER_8F", + "width": 10 + }, + "TIN_TOWER_9F": { + "group": 3, + "height": 9, + "map": 12, + "name": "TIN_TOWER_9F", + "width": 10 + }, + "TIN_TOWER_ROOF": { + "group": 15, + "height": 9, + "map": 12, + "name": "TIN_TOWER_ROOF", + "width": 10 + }, + "TOHJO_FALLS": { + "group": 3, + "height": 9, + "map": 83, + "name": "TOHJO_FALLS", + "width": 15 + }, + "TRADE_CENTER": { + "group": 20, + "height": 4, + "map": 2, + "name": "TRADE_CENTER", + "width": 5 + }, + "TRAINER_HOUSE_1F": { + "group": 23, + "height": 7, + "map": 6, + "name": "TRAINER_HOUSE_1F", + "width": 5 + }, + "TRAINER_HOUSE_B1F": { + "group": 23, + "height": 8, + "map": 7, + "name": "TRAINER_HOUSE_B1F", + "width": 5 + }, + "UNDERGROUND_PATH": { + "group": 3, + "height": 14, + "map": 86, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + "UNION_CAVE_1F": { + "group": 3, + "height": 18, + "map": 37, + "name": "UNION_CAVE_1F", + "width": 10 + }, + "UNION_CAVE_B1F": { + "group": 3, + "height": 18, + "map": 38, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + "UNION_CAVE_B2F": { + "group": 3, + "height": 18, + "map": 39, + "name": "UNION_CAVE_B2F", + "width": 10 + }, + "VERMILION_CITY": { + "group": 12, + "height": 18, + "map": 3, + "name": "VERMILION_CITY", + "width": 20 + }, + "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 10, + "name": "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_FISHING_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 4, + "name": "VERMILION_FISHING_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_GYM": { + "group": 12, + "height": 9, + "map": 11, + "name": "VERMILION_GYM", + "width": 5 + }, + "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 8, + "name": "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_MART": { + "group": 12, + "height": 4, + "map": 9, + "name": "VERMILION_MART", + "width": 6 + }, + "VERMILION_POKECENTER_1F": { + "group": 12, + "height": 4, + "map": 5, + "name": "VERMILION_POKECENTER_1F", + "width": 5 + }, + "VERMILION_POKECENTER_2F_BETA": { + "group": 12, + "height": 4, + "map": 6, + "name": "VERMILION_POKECENTER_2F_BETA", + "width": 8 + }, + "VERMILION_PORT": { + "group": 15, + "height": 18, + "map": 2, + "name": "VERMILION_PORT", + "width": 10 + }, + "VERMILION_PORT_PASSAGE": { + "group": 15, + "height": 9, + "map": 9, + "name": "VERMILION_PORT_PASSAGE", + "width": 10 + }, + "VICTORY_ROAD": { + "group": 3, + "height": 36, + "map": 91, + "name": "VICTORY_ROAD", + "width": 10 + }, + "VICTORY_ROAD_GATE": { + "group": 23, + "height": 9, + "map": 13, + "name": "VICTORY_ROAD_GATE", + "width": 10 + }, + "VIOLET_CITY": { + "group": 10, + "height": 18, + "map": 5, + "name": "VIOLET_CITY", + "width": 20 + }, + "VIOLET_GYM": { + "group": 10, + "height": 8, + "map": 7, + "name": "VIOLET_GYM", + "width": 5 + }, + "VIOLET_KYLES_HOUSE": { + "group": 10, + "height": 4, + "map": 11, + "name": "VIOLET_KYLES_HOUSE", + "width": 4 + }, + "VIOLET_MART": { + "group": 10, + "height": 4, + "map": 6, + "name": "VIOLET_MART", + "width": 6 + }, + "VIOLET_NICKNAME_SPEECH_HOUSE": { + "group": 10, + "height": 4, + "map": 9, + "name": "VIOLET_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + "VIOLET_POKECENTER_1F": { + "group": 10, + "height": 4, + "map": 10, + "name": "VIOLET_POKECENTER_1F", + "width": 5 + }, + "VIRIDIAN_CITY": { + "group": 23, + "height": 18, + "map": 3, + "name": "VIRIDIAN_CITY", + "width": 20 + }, + "VIRIDIAN_GYM": { + "group": 23, + "height": 9, + "map": 4, + "name": "VIRIDIAN_GYM", + "width": 5 + }, + "VIRIDIAN_MART": { + "group": 23, + "height": 4, + "map": 8, + "name": "VIRIDIAN_MART", + "width": 6 + }, + "VIRIDIAN_NICKNAME_SPEECH_HOUSE": { + "group": 23, + "height": 4, + "map": 5, + "name": "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + "VIRIDIAN_POKECENTER_1F": { + "group": 23, + "height": 4, + "map": 9, + "name": "VIRIDIAN_POKECENTER_1F", + "width": 5 + }, + "VIRIDIAN_POKECENTER_2F_BETA": { + "group": 23, + "height": 4, + "map": 10, + "name": "VIRIDIAN_POKECENTER_2F_BETA", + "width": 8 + }, + "WHIRL_ISLAND_B1F": { + "group": 3, + "height": 18, + "map": 71, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + "WHIRL_ISLAND_B2F": { + "group": 3, + "height": 18, + "map": 72, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + "WHIRL_ISLAND_CAVE": { + "group": 3, + "height": 9, + "map": 69, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + "WHIRL_ISLAND_LUGIA_CHAMBER": { + "group": 3, + "height": 9, + "map": 73, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + "WHIRL_ISLAND_NE": { + "group": 3, + "height": 9, + "map": 67, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + "WHIRL_ISLAND_NW": { + "group": 3, + "height": 9, + "map": 66, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + "WHIRL_ISLAND_SE": { + "group": 3, + "height": 9, + "map": 70, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + "WHIRL_ISLAND_SW": { + "group": 3, + "height": 9, + "map": 68, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + "WILLS_ROOM": { + "group": 16, + "height": 9, + "map": 3, + "name": "WILLS_ROOM", + "width": 5 + }, + "WISE_TRIOS_ROOM": { + "group": 4, + "height": 4, + "map": 2, + "name": "WISE_TRIOS_ROOM", + "width": 4 + } + }, + "pokemonAssets": { + "ABRA": { + "animLabel": "AbraAnimation", + "back": "abra_back", + "backLabel": "AbraBackpic", + "bitmaskLabel": "AbraBitmasks", + "dexLabel": "AbraPokedexEntry", + "framesLabel": "AbraFrames", + "front": "abra", + "frontLabel": "AbraFrontpic", + "id": "ABRA", + "idleLabel": "AbraAnimationIdle", + "name": "ABRA" + }, + "AERODACTYL": { + "animLabel": "AerodactylAnimation", + "back": "aerodactyl_back", + "backLabel": "AerodactylBackpic", + "bitmaskLabel": "AerodactylBitmasks", + "dexLabel": "AerodactylPokedexEntry", + "framesLabel": "AerodactylFrames", + "front": "aerodactyl", + "frontLabel": "AerodactylFrontpic", + "id": "AERODACTYL", + "idleLabel": "AerodactylAnimationIdle", + "name": "AERODACTYL" + }, + "AIPOM": { + "animLabel": "AipomAnimation", + "back": "aipom_back", + "backLabel": "AipomBackpic", + "bitmaskLabel": "AipomBitmasks", + "dexLabel": "AipomPokedexEntry", + "framesLabel": "AipomFrames", + "front": "aipom", + "frontLabel": "AipomFrontpic", + "id": "AIPOM", + "idleLabel": "AipomAnimationIdle", + "name": "AIPOM" + }, + "ALAKAZAM": { + "animLabel": "AlakazamAnimation", + "back": "alakazam_back", + "backLabel": "AlakazamBackpic", + "bitmaskLabel": "AlakazamBitmasks", + "dexLabel": "AlakazamPokedexEntry", + "framesLabel": "AlakazamFrames", + "front": "alakazam", + "frontLabel": "AlakazamFrontpic", + "id": "ALAKAZAM", + "idleLabel": "AlakazamAnimationIdle", + "name": "ALAKAZAM" + }, + "AMPHAROS": { + "animLabel": "AmpharosAnimation", + "back": "ampharos_back", + "backLabel": "AmpharosBackpic", + "bitmaskLabel": "AmpharosBitmasks", + "dexLabel": "AmpharosPokedexEntry", + "framesLabel": "AmpharosFrames", + "front": "ampharos", + "frontLabel": "AmpharosFrontpic", + "id": "AMPHAROS", + "idleLabel": "AmpharosAnimationIdle", + "name": "AMPHAROS" + }, + "ARBOK": { + "animLabel": "ArbokAnimation", + "back": "arbok_back", + "backLabel": "ArbokBackpic", + "bitmaskLabel": "ArbokBitmasks", + "dexLabel": "ArbokPokedexEntry", + "framesLabel": "ArbokFrames", + "front": "arbok", + "frontLabel": "ArbokFrontpic", + "id": "ARBOK", + "idleLabel": "ArbokAnimationIdle", + "name": "ARBOK" + }, + "ARCANINE": { + "animLabel": "ArcanineAnimation", + "back": "arcanine_back", + "backLabel": "ArcanineBackpic", + "bitmaskLabel": "ArcanineBitmasks", + "dexLabel": "ArcaninePokedexEntry", + "framesLabel": "ArcanineFrames", + "front": "arcanine", + "frontLabel": "ArcanineFrontpic", + "id": "ARCANINE", + "idleLabel": "ArcanineAnimationIdle", + "name": "ARCANINE" + }, + "ARIADOS": { + "animLabel": "AriadosAnimation", + "back": "ariados_back", + "backLabel": "AriadosBackpic", + "bitmaskLabel": "AriadosBitmasks", + "dexLabel": "AriadosPokedexEntry", + "framesLabel": "AriadosFrames", + "front": "ariados", + "frontLabel": "AriadosFrontpic", + "id": "ARIADOS", + "idleLabel": "AriadosAnimationIdle", + "name": "ARIADOS" + }, + "ARTICUNO": { + "animLabel": "ArticunoAnimation", + "back": "articuno_back", + "backLabel": "ArticunoBackpic", + "bitmaskLabel": "ArticunoBitmasks", + "dexLabel": "ArticunoPokedexEntry", + "framesLabel": "ArticunoFrames", + "front": "articuno", + "frontLabel": "ArticunoFrontpic", + "id": "ARTICUNO", + "idleLabel": "ArticunoAnimationIdle", + "name": "ARTICUNO" + }, + "AZUMARILL": { + "animLabel": "AzumarillAnimation", + "back": "azumarill_back", + "backLabel": "AzumarillBackpic", + "bitmaskLabel": "AzumarillBitmasks", + "dexLabel": "AzumarillPokedexEntry", + "framesLabel": "AzumarillFrames", + "front": "azumarill", + "frontLabel": "AzumarillFrontpic", + "id": "AZUMARILL", + "idleLabel": "AzumarillAnimationIdle", + "name": "AZUMARILL" + }, + "BAYLEEF": { + "animLabel": "BayleefAnimation", + "back": "bayleef_back", + "backLabel": "BayleefBackpic", + "bitmaskLabel": "BayleefBitmasks", + "dexLabel": "BayleefPokedexEntry", + "framesLabel": "BayleefFrames", + "front": "bayleef", + "frontLabel": "BayleefFrontpic", + "id": "BAYLEEF", + "idleLabel": "BayleefAnimationIdle", + "name": "BAYLEEF" + }, + "BEEDRILL": { + "animLabel": "BeedrillAnimation", + "back": "beedrill_back", + "backLabel": "BeedrillBackpic", + "bitmaskLabel": "BeedrillBitmasks", + "dexLabel": "BeedrillPokedexEntry", + "framesLabel": "BeedrillFrames", + "front": "beedrill", + "frontLabel": "BeedrillFrontpic", + "id": "BEEDRILL", + "idleLabel": "BeedrillAnimationIdle", + "name": "BEEDRILL" + }, + "BELLOSSOM": { + "animLabel": "BellossomAnimation", + "back": "bellossom_back", + "backLabel": "BellossomBackpic", + "bitmaskLabel": "BellossomBitmasks", + "dexLabel": "BellossomPokedexEntry", + "framesLabel": "BellossomFrames", + "front": "bellossom", + "frontLabel": "BellossomFrontpic", + "id": "BELLOSSOM", + "idleLabel": "BellossomAnimationIdle", + "name": "BELLOSSOM" + }, + "BELLSPROUT": { + "animLabel": "BellsproutAnimation", + "back": "bellsprout_back", + "backLabel": "BellsproutBackpic", + "bitmaskLabel": "BellsproutBitmasks", + "dexLabel": "BellsproutPokedexEntry", + "framesLabel": "BellsproutFrames", + "front": "bellsprout", + "frontLabel": "BellsproutFrontpic", + "id": "BELLSPROUT", + "idleLabel": "BellsproutAnimationIdle", + "name": "BELLSPROUT" + }, + "BLASTOISE": { + "animLabel": "BlastoiseAnimation", + "back": "blastoise_back", + "backLabel": "BlastoiseBackpic", + "bitmaskLabel": "BlastoiseBitmasks", + "dexLabel": "BlastoisePokedexEntry", + "framesLabel": "BlastoiseFrames", + "front": "blastoise", + "frontLabel": "BlastoiseFrontpic", + "id": "BLASTOISE", + "idleLabel": "BlastoiseAnimationIdle", + "name": "BLASTOISE" + }, + "BLISSEY": { + "animLabel": "BlisseyAnimation", + "back": "blissey_back", + "backLabel": "BlisseyBackpic", + "bitmaskLabel": "BlisseyBitmasks", + "dexLabel": "BlisseyPokedexEntry", + "framesLabel": "BlisseyFrames", + "front": "blissey", + "frontLabel": "BlisseyFrontpic", + "id": "BLISSEY", + "idleLabel": "BlisseyAnimationIdle", + "name": "BLISSEY" + }, + "BULBASAUR": { + "animLabel": "BulbasaurAnimation", + "back": "bulbasaur_back", + "backLabel": "BulbasaurBackpic", + "bitmaskLabel": "BulbasaurBitmasks", + "dexLabel": "BulbasaurPokedexEntry", + "framesLabel": "BulbasaurFrames", + "front": "bulbasaur", + "frontLabel": "BulbasaurFrontpic", + "id": "BULBASAUR", + "idleLabel": "BulbasaurAnimationIdle", + "name": "BULBASAUR" + }, + "BUTTERFREE": { + "animLabel": "ButterfreeAnimation", + "back": "butterfree_back", + "backLabel": "ButterfreeBackpic", + "bitmaskLabel": "ButterfreeBitmasks", + "dexLabel": "ButterfreePokedexEntry", + "framesLabel": "ButterfreeFrames", + "front": "butterfree", + "frontLabel": "ButterfreeFrontpic", + "id": "BUTTERFREE", + "idleLabel": "ButterfreeAnimationIdle", + "name": "BUTTERFREE" + }, + "CATERPIE": { + "animLabel": "CaterpieAnimation", + "back": "caterpie_back", + "backLabel": "CaterpieBackpic", + "bitmaskLabel": "CaterpieBitmasks", + "dexLabel": "CaterpiePokedexEntry", + "framesLabel": "CaterpieFrames", + "front": "caterpie", + "frontLabel": "CaterpieFrontpic", + "id": "CATERPIE", + "idleLabel": "CaterpieAnimationIdle", + "name": "CATERPIE" + }, + "CELEBI": { + "animLabel": "CelebiAnimation", + "back": "celebi_back", + "backLabel": "CelebiBackpic", + "bitmaskLabel": "CelebiBitmasks", + "dexLabel": "CelebiPokedexEntry", + "framesLabel": "CelebiFrames", + "front": "celebi", + "frontLabel": "CelebiFrontpic", + "id": "CELEBI", + "idleLabel": "CelebiAnimationIdle", + "name": "CELEBI" + }, + "CHANSEY": { + "animLabel": "ChanseyAnimation", + "back": "chansey_back", + "backLabel": "ChanseyBackpic", + "bitmaskLabel": "ChanseyBitmasks", + "dexLabel": "ChanseyPokedexEntry", + "framesLabel": "ChanseyFrames", + "front": "chansey", + "frontLabel": "ChanseyFrontpic", + "id": "CHANSEY", + "idleLabel": "ChanseyAnimationIdle", + "name": "CHANSEY" + }, + "CHARIZARD": { + "animLabel": "CharizardAnimation", + "back": "charizard_back", + "backLabel": "CharizardBackpic", + "bitmaskLabel": "CharizardBitmasks", + "dexLabel": "CharizardPokedexEntry", + "framesLabel": "CharizardFrames", + "front": "charizard", + "frontLabel": "CharizardFrontpic", + "id": "CHARIZARD", + "idleLabel": "CharizardAnimationIdle", + "name": "CHARIZARD" + }, + "CHARMANDER": { + "animLabel": "CharmanderAnimation", + "back": "charmander_back", + "backLabel": "CharmanderBackpic", + "bitmaskLabel": "CharmanderBitmasks", + "dexLabel": "CharmanderPokedexEntry", + "framesLabel": "CharmanderFrames", + "front": "charmander", + "frontLabel": "CharmanderFrontpic", + "id": "CHARMANDER", + "idleLabel": "CharmanderAnimationIdle", + "name": "CHARMANDER" + }, + "CHARMELEON": { + "animLabel": "CharmeleonAnimation", + "back": "charmeleon_back", + "backLabel": "CharmeleonBackpic", + "bitmaskLabel": "CharmeleonBitmasks", + "dexLabel": "CharmeleonPokedexEntry", + "framesLabel": "CharmeleonFrames", + "front": "charmeleon", + "frontLabel": "CharmeleonFrontpic", + "id": "CHARMELEON", + "idleLabel": "CharmeleonAnimationIdle", + "name": "CHARMELEON" + }, + "CHIKORITA": { + "animLabel": "ChikoritaAnimation", + "back": "chikorita_back", + "backLabel": "ChikoritaBackpic", + "bitmaskLabel": "ChikoritaBitmasks", + "dexLabel": "ChikoritaPokedexEntry", + "framesLabel": "ChikoritaFrames", + "front": "chikorita", + "frontLabel": "ChikoritaFrontpic", + "id": "CHIKORITA", + "idleLabel": "ChikoritaAnimationIdle", + "name": "CHIKORITA" + }, + "CHINCHOU": { + "animLabel": "ChinchouAnimation", + "back": "chinchou_back", + "backLabel": "ChinchouBackpic", + "bitmaskLabel": "ChinchouBitmasks", + "dexLabel": "ChinchouPokedexEntry", + "framesLabel": "ChinchouFrames", + "front": "chinchou", + "frontLabel": "ChinchouFrontpic", + "id": "CHINCHOU", + "idleLabel": "ChinchouAnimationIdle", + "name": "CHINCHOU" + }, + "CLEFABLE": { + "animLabel": "ClefableAnimation", + "back": "clefable_back", + "backLabel": "ClefableBackpic", + "bitmaskLabel": "ClefableBitmasks", + "dexLabel": "ClefablePokedexEntry", + "framesLabel": "ClefableFrames", + "front": "clefable", + "frontLabel": "ClefableFrontpic", + "id": "CLEFABLE", + "idleLabel": "ClefableAnimationIdle", + "name": "CLEFABLE" + }, + "CLEFAIRY": { + "animLabel": "ClefairyAnimation", + "back": "clefairy_back", + "backLabel": "ClefairyBackpic", + "bitmaskLabel": "ClefairyBitmasks", + "dexLabel": "ClefairyPokedexEntry", + "framesLabel": "ClefairyFrames", + "front": "clefairy", + "frontLabel": "ClefairyFrontpic", + "id": "CLEFAIRY", + "idleLabel": "ClefairyAnimationIdle", + "name": "CLEFAIRY" + }, + "CLEFFA": { + "animLabel": "CleffaAnimation", + "back": "cleffa_back", + "backLabel": "CleffaBackpic", + "bitmaskLabel": "CleffaBitmasks", + "dexLabel": "CleffaPokedexEntry", + "framesLabel": "CleffaFrames", + "front": "cleffa", + "frontLabel": "CleffaFrontpic", + "id": "CLEFFA", + "idleLabel": "CleffaAnimationIdle", + "name": "CLEFFA" + }, + "CLOYSTER": { + "animLabel": "CloysterAnimation", + "back": "cloyster_back", + "backLabel": "CloysterBackpic", + "bitmaskLabel": "CloysterBitmasks", + "dexLabel": "CloysterPokedexEntry", + "framesLabel": "CloysterFrames", + "front": "cloyster", + "frontLabel": "CloysterFrontpic", + "id": "CLOYSTER", + "idleLabel": "CloysterAnimationIdle", + "name": "CLOYSTER" + }, + "CORSOLA": { + "animLabel": "CorsolaAnimation", + "back": "corsola_back", + "backLabel": "CorsolaBackpic", + "bitmaskLabel": "CorsolaBitmasks", + "dexLabel": "CorsolaPokedexEntry", + "framesLabel": "CorsolaFrames", + "front": "corsola", + "frontLabel": "CorsolaFrontpic", + "id": "CORSOLA", + "idleLabel": "CorsolaAnimationIdle", + "name": "CORSOLA" + }, + "CROBAT": { + "animLabel": "CrobatAnimation", + "back": "crobat_back", + "backLabel": "CrobatBackpic", + "bitmaskLabel": "CrobatBitmasks", + "dexLabel": "CrobatPokedexEntry", + "framesLabel": "CrobatFrames", + "front": "crobat", + "frontLabel": "CrobatFrontpic", + "id": "CROBAT", + "idleLabel": "CrobatAnimationIdle", + "name": "CROBAT" + }, + "CROCONAW": { + "animLabel": "CroconawAnimation", + "back": "croconaw_back", + "backLabel": "CroconawBackpic", + "bitmaskLabel": "CroconawBitmasks", + "dexLabel": "CroconawPokedexEntry", + "framesLabel": "CroconawFrames", + "front": "croconaw", + "frontLabel": "CroconawFrontpic", + "id": "CROCONAW", + "idleLabel": "CroconawAnimationIdle", + "name": "CROCONAW" + }, + "CUBONE": { + "animLabel": "CuboneAnimation", + "back": "cubone_back", + "backLabel": "CuboneBackpic", + "bitmaskLabel": "CuboneBitmasks", + "dexLabel": "CubonePokedexEntry", + "framesLabel": "CuboneFrames", + "front": "cubone", + "frontLabel": "CuboneFrontpic", + "id": "CUBONE", + "idleLabel": "CuboneAnimationIdle", + "name": "CUBONE" + }, + "CYNDAQUIL": { + "animLabel": "CyndaquilAnimation", + "back": "cyndaquil_back", + "backLabel": "CyndaquilBackpic", + "bitmaskLabel": "CyndaquilBitmasks", + "dexLabel": "CyndaquilPokedexEntry", + "framesLabel": "CyndaquilFrames", + "front": "cyndaquil", + "frontLabel": "CyndaquilFrontpic", + "id": "CYNDAQUIL", + "idleLabel": "CyndaquilAnimationIdle", + "name": "CYNDAQUIL" + }, + "DELIBIRD": { + "animLabel": "DelibirdAnimation", + "back": "delibird_back", + "backLabel": "DelibirdBackpic", + "bitmaskLabel": "DelibirdBitmasks", + "dexLabel": "DelibirdPokedexEntry", + "framesLabel": "DelibirdFrames", + "front": "delibird", + "frontLabel": "DelibirdFrontpic", + "id": "DELIBIRD", + "idleLabel": "DelibirdAnimationIdle", + "name": "DELIBIRD" + }, + "DEWGONG": { + "animLabel": "DewgongAnimation", + "back": "dewgong_back", + "backLabel": "DewgongBackpic", + "bitmaskLabel": "DewgongBitmasks", + "dexLabel": "DewgongPokedexEntry", + "framesLabel": "DewgongFrames", + "front": "dewgong", + "frontLabel": "DewgongFrontpic", + "id": "DEWGONG", + "idleLabel": "DewgongAnimationIdle", + "name": "DEWGONG" + }, + "DIGLETT": { + "animLabel": "DiglettAnimation", + "back": "diglett_back", + "backLabel": "DiglettBackpic", + "bitmaskLabel": "DiglettBitmasks", + "dexLabel": "DiglettPokedexEntry", + "framesLabel": "DiglettFrames", + "front": "diglett", + "frontLabel": "DiglettFrontpic", + "id": "DIGLETT", + "idleLabel": "DiglettAnimationIdle", + "name": "DIGLETT" + }, + "DITTO": { + "animLabel": "DittoAnimation", + "back": "ditto_back", + "backLabel": "DittoBackpic", + "bitmaskLabel": "DittoBitmasks", + "dexLabel": "DittoPokedexEntry", + "framesLabel": "DittoFrames", + "front": "ditto", + "frontLabel": "DittoFrontpic", + "id": "DITTO", + "idleLabel": "DittoAnimationIdle", + "name": "DITTO" + }, + "DODRIO": { + "animLabel": "DodrioAnimation", + "back": "dodrio_back", + "backLabel": "DodrioBackpic", + "bitmaskLabel": "DodrioBitmasks", + "dexLabel": "DodrioPokedexEntry", + "framesLabel": "DodrioFrames", + "front": "dodrio", + "frontLabel": "DodrioFrontpic", + "id": "DODRIO", + "idleLabel": "DodrioAnimationIdle", + "name": "DODRIO" + }, + "DODUO": { + "animLabel": "DoduoAnimation", + "back": "doduo_back", + "backLabel": "DoduoBackpic", + "bitmaskLabel": "DoduoBitmasks", + "dexLabel": "DoduoPokedexEntry", + "framesLabel": "DoduoFrames", + "front": "doduo", + "frontLabel": "DoduoFrontpic", + "id": "DODUO", + "idleLabel": "DoduoAnimationIdle", + "name": "DODUO" + }, + "DONPHAN": { + "animLabel": "DonphanAnimation", + "back": "donphan_back", + "backLabel": "DonphanBackpic", + "bitmaskLabel": "DonphanBitmasks", + "dexLabel": "DonphanPokedexEntry", + "framesLabel": "DonphanFrames", + "front": "donphan", + "frontLabel": "DonphanFrontpic", + "id": "DONPHAN", + "idleLabel": "DonphanAnimationIdle", + "name": "DONPHAN" + }, + "DRAGONAIR": { + "animLabel": "DragonairAnimation", + "back": "dragonair_back", + "backLabel": "DragonairBackpic", + "bitmaskLabel": "DragonairBitmasks", + "dexLabel": "DragonairPokedexEntry", + "framesLabel": "DragonairFrames", + "front": "dragonair", + "frontLabel": "DragonairFrontpic", + "id": "DRAGONAIR", + "idleLabel": "DragonairAnimationIdle", + "name": "DRAGONAIR" + }, + "DRAGONITE": { + "animLabel": "DragoniteAnimation", + "back": "dragonite_back", + "backLabel": "DragoniteBackpic", + "bitmaskLabel": "DragoniteBitmasks", + "dexLabel": "DragonitePokedexEntry", + "framesLabel": "DragoniteFrames", + "front": "dragonite", + "frontLabel": "DragoniteFrontpic", + "id": "DRAGONITE", + "idleLabel": "DragoniteAnimationIdle", + "name": "DRAGONITE" + }, + "DRATINI": { + "animLabel": "DratiniAnimation", + "back": "dratini_back", + "backLabel": "DratiniBackpic", + "bitmaskLabel": "DratiniBitmasks", + "dexLabel": "DratiniPokedexEntry", + "framesLabel": "DratiniFrames", + "front": "dratini", + "frontLabel": "DratiniFrontpic", + "id": "DRATINI", + "idleLabel": "DratiniAnimationIdle", + "name": "DRATINI" + }, + "DROWZEE": { + "animLabel": "DrowzeeAnimation", + "back": "drowzee_back", + "backLabel": "DrowzeeBackpic", + "bitmaskLabel": "DrowzeeBitmasks", + "dexLabel": "DrowzeePokedexEntry", + "framesLabel": "DrowzeeFrames", + "front": "drowzee", + "frontLabel": "DrowzeeFrontpic", + "id": "DROWZEE", + "idleLabel": "DrowzeeAnimationIdle", + "name": "DROWZEE" + }, + "DUGTRIO": { + "animLabel": "DugtrioAnimation", + "back": "dugtrio_back", + "backLabel": "DugtrioBackpic", + "bitmaskLabel": "DugtrioBitmasks", + "dexLabel": "DugtrioPokedexEntry", + "framesLabel": "DugtrioFrames", + "front": "dugtrio", + "frontLabel": "DugtrioFrontpic", + "id": "DUGTRIO", + "idleLabel": "DugtrioAnimationIdle", + "name": "DUGTRIO" + }, + "DUNSPARCE": { + "animLabel": "DunsparceAnimation", + "back": "dunsparce_back", + "backLabel": "DunsparceBackpic", + "bitmaskLabel": "DunsparceBitmasks", + "dexLabel": "DunsparcePokedexEntry", + "framesLabel": "DunsparceFrames", + "front": "dunsparce", + "frontLabel": "DunsparceFrontpic", + "id": "DUNSPARCE", + "idleLabel": "DunsparceAnimationIdle", + "name": "DUNSPARCE" + }, + "EEVEE": { + "animLabel": "EeveeAnimation", + "back": "eevee_back", + "backLabel": "EeveeBackpic", + "bitmaskLabel": "EeveeBitmasks", + "dexLabel": "EeveePokedexEntry", + "framesLabel": "EeveeFrames", + "front": "eevee", + "frontLabel": "EeveeFrontpic", + "id": "EEVEE", + "idleLabel": "EeveeAnimationIdle", + "name": "EEVEE" + }, + "EKANS": { + "animLabel": "EkansAnimation", + "back": "ekans_back", + "backLabel": "EkansBackpic", + "bitmaskLabel": "EkansBitmasks", + "dexLabel": "EkansPokedexEntry", + "framesLabel": "EkansFrames", + "front": "ekans", + "frontLabel": "EkansFrontpic", + "id": "EKANS", + "idleLabel": "EkansAnimationIdle", + "name": "EKANS" + }, + "ELECTABUZZ": { + "animLabel": "ElectabuzzAnimation", + "back": "electabuzz_back", + "backLabel": "ElectabuzzBackpic", + "bitmaskLabel": "ElectabuzzBitmasks", + "dexLabel": "ElectabuzzPokedexEntry", + "framesLabel": "ElectabuzzFrames", + "front": "electabuzz", + "frontLabel": "ElectabuzzFrontpic", + "id": "ELECTABUZZ", + "idleLabel": "ElectabuzzAnimationIdle", + "name": "ELECTABUZZ" + }, + "ELECTRODE": { + "animLabel": "ElectrodeAnimation", + "back": "electrode_back", + "backLabel": "ElectrodeBackpic", + "bitmaskLabel": "ElectrodeBitmasks", + "dexLabel": "ElectrodePokedexEntry", + "framesLabel": "ElectrodeFrames", + "front": "electrode", + "frontLabel": "ElectrodeFrontpic", + "id": "ELECTRODE", + "idleLabel": "ElectrodeAnimationIdle", + "name": "ELECTRODE" + }, + "ELEKID": { + "animLabel": "ElekidAnimation", + "back": "elekid_back", + "backLabel": "ElekidBackpic", + "bitmaskLabel": "ElekidBitmasks", + "dexLabel": "ElekidPokedexEntry", + "framesLabel": "ElekidFrames", + "front": "elekid", + "frontLabel": "ElekidFrontpic", + "id": "ELEKID", + "idleLabel": "ElekidAnimationIdle", + "name": "ELEKID" + }, + "ENTEI": { + "animLabel": "EnteiAnimation", + "back": "entei_back", + "backLabel": "EnteiBackpic", + "bitmaskLabel": "EnteiBitmasks", + "dexLabel": "EnteiPokedexEntry", + "framesLabel": "EnteiFrames", + "front": "entei", + "frontLabel": "EnteiFrontpic", + "id": "ENTEI", + "idleLabel": "EnteiAnimationIdle", + "name": "ENTEI" + }, + "ESPEON": { + "animLabel": "EspeonAnimation", + "back": "espeon_back", + "backLabel": "EspeonBackpic", + "bitmaskLabel": "EspeonBitmasks", + "dexLabel": "EspeonPokedexEntry", + "framesLabel": "EspeonFrames", + "front": "espeon", + "frontLabel": "EspeonFrontpic", + "id": "ESPEON", + "idleLabel": "EspeonAnimationIdle", + "name": "ESPEON" + }, + "EXEGGCUTE": { + "animLabel": "ExeggcuteAnimation", + "back": "exeggcute_back", + "backLabel": "ExeggcuteBackpic", + "bitmaskLabel": "ExeggcuteBitmasks", + "dexLabel": "ExeggcutePokedexEntry", + "framesLabel": "ExeggcuteFrames", + "front": "exeggcute", + "frontLabel": "ExeggcuteFrontpic", + "id": "EXEGGCUTE", + "idleLabel": "ExeggcuteAnimationIdle", + "name": "EXEGGCUTE" + }, + "EXEGGUTOR": { + "animLabel": "ExeggutorAnimation", + "back": "exeggutor_back", + "backLabel": "ExeggutorBackpic", + "bitmaskLabel": "ExeggutorBitmasks", + "dexLabel": "ExeggutorPokedexEntry", + "framesLabel": "ExeggutorFrames", + "front": "exeggutor", + "frontLabel": "ExeggutorFrontpic", + "id": "EXEGGUTOR", + "idleLabel": "ExeggutorAnimationIdle", + "name": "EXEGGUTOR" + }, + "FARFETCH_D": { + "animLabel": "FarfetchDAnimation", + "back": "farfetchd_back", + "backLabel": "FarfetchDBackpic", + "bitmaskLabel": "FarfetchDBitmasks", + "dexLabel": "FarfetchDPokedexEntry", + "framesLabel": "FarfetchDFrames", + "front": "farfetchd", + "frontLabel": "FarfetchDFrontpic", + "id": "FARFETCH_D", + "idleLabel": "FarfetchDAnimationIdle", + "name": "FARFETCH'D" + }, + "FEAROW": { + "animLabel": "FearowAnimation", + "back": "fearow_back", + "backLabel": "FearowBackpic", + "bitmaskLabel": "FearowBitmasks", + "dexLabel": "FearowPokedexEntry", + "framesLabel": "FearowFrames", + "front": "fearow", + "frontLabel": "FearowFrontpic", + "id": "FEAROW", + "idleLabel": "FearowAnimationIdle", + "name": "FEAROW" + }, + "FERALIGATR": { + "animLabel": "FeraligatrAnimation", + "back": "feraligatr_back", + "backLabel": "FeraligatrBackpic", + "bitmaskLabel": "FeraligatrBitmasks", + "dexLabel": "FeraligatrPokedexEntry", + "framesLabel": "FeraligatrFrames", + "front": "feraligatr", + "frontLabel": "FeraligatrFrontpic", + "id": "FERALIGATR", + "idleLabel": "FeraligatrAnimationIdle", + "name": "FERALIGATR" + }, + "FLAAFFY": { + "animLabel": "FlaaffyAnimation", + "back": "flaaffy_back", + "backLabel": "FlaaffyBackpic", + "bitmaskLabel": "FlaaffyBitmasks", + "dexLabel": "FlaaffyPokedexEntry", + "framesLabel": "FlaaffyFrames", + "front": "flaaffy", + "frontLabel": "FlaaffyFrontpic", + "id": "FLAAFFY", + "idleLabel": "FlaaffyAnimationIdle", + "name": "FLAAFFY" + }, + "FLAREON": { + "animLabel": "FlareonAnimation", + "back": "flareon_back", + "backLabel": "FlareonBackpic", + "bitmaskLabel": "FlareonBitmasks", + "dexLabel": "FlareonPokedexEntry", + "framesLabel": "FlareonFrames", + "front": "flareon", + "frontLabel": "FlareonFrontpic", + "id": "FLAREON", + "idleLabel": "FlareonAnimationIdle", + "name": "FLAREON" + }, + "FORRETRESS": { + "animLabel": "ForretressAnimation", + "back": "forretress_back", + "backLabel": "ForretressBackpic", + "bitmaskLabel": "ForretressBitmasks", + "dexLabel": "ForretressPokedexEntry", + "framesLabel": "ForretressFrames", + "front": "forretress", + "frontLabel": "ForretressFrontpic", + "id": "FORRETRESS", + "idleLabel": "ForretressAnimationIdle", + "name": "FORRETRESS" + }, + "FURRET": { + "animLabel": "FurretAnimation", + "back": "furret_back", + "backLabel": "FurretBackpic", + "bitmaskLabel": "FurretBitmasks", + "dexLabel": "FurretPokedexEntry", + "framesLabel": "FurretFrames", + "front": "furret", + "frontLabel": "FurretFrontpic", + "id": "FURRET", + "idleLabel": "FurretAnimationIdle", + "name": "FURRET" + }, + "GASTLY": { + "animLabel": "GastlyAnimation", + "back": "gastly_back", + "backLabel": "GastlyBackpic", + "bitmaskLabel": "GastlyBitmasks", + "dexLabel": "GastlyPokedexEntry", + "framesLabel": "GastlyFrames", + "front": "gastly", + "frontLabel": "GastlyFrontpic", + "id": "GASTLY", + "idleLabel": "GastlyAnimationIdle", + "name": "GASTLY" + }, + "GENGAR": { + "animLabel": "GengarAnimation", + "back": "gengar_back", + "backLabel": "GengarBackpic", + "bitmaskLabel": "GengarBitmasks", + "dexLabel": "GengarPokedexEntry", + "framesLabel": "GengarFrames", + "front": "gengar", + "frontLabel": "GengarFrontpic", + "id": "GENGAR", + "idleLabel": "GengarAnimationIdle", + "name": "GENGAR" + }, + "GEODUDE": { + "animLabel": "GeodudeAnimation", + "back": "geodude_back", + "backLabel": "GeodudeBackpic", + "bitmaskLabel": "GeodudeBitmasks", + "dexLabel": "GeodudePokedexEntry", + "framesLabel": "GeodudeFrames", + "front": "geodude", + "frontLabel": "GeodudeFrontpic", + "id": "GEODUDE", + "idleLabel": "GeodudeAnimationIdle", + "name": "GEODUDE" + }, + "GIRAFARIG": { + "animLabel": "GirafarigAnimation", + "back": "girafarig_back", + "backLabel": "GirafarigBackpic", + "bitmaskLabel": "GirafarigBitmasks", + "dexLabel": "GirafarigPokedexEntry", + "framesLabel": "GirafarigFrames", + "front": "girafarig", + "frontLabel": "GirafarigFrontpic", + "id": "GIRAFARIG", + "idleLabel": "GirafarigAnimationIdle", + "name": "GIRAFARIG" + }, + "GLIGAR": { + "animLabel": "GligarAnimation", + "back": "gligar_back", + "backLabel": "GligarBackpic", + "bitmaskLabel": "GligarBitmasks", + "dexLabel": "GligarPokedexEntry", + "framesLabel": "GligarFrames", + "front": "gligar", + "frontLabel": "GligarFrontpic", + "id": "GLIGAR", + "idleLabel": "GligarAnimationIdle", + "name": "GLIGAR" + }, + "GLOOM": { + "animLabel": "GloomAnimation", + "back": "gloom_back", + "backLabel": "GloomBackpic", + "bitmaskLabel": "GloomBitmasks", + "dexLabel": "GloomPokedexEntry", + "framesLabel": "GloomFrames", + "front": "gloom", + "frontLabel": "GloomFrontpic", + "id": "GLOOM", + "idleLabel": "GloomAnimationIdle", + "name": "GLOOM" + }, + "GOLBAT": { + "animLabel": "GolbatAnimation", + "back": "golbat_back", + "backLabel": "GolbatBackpic", + "bitmaskLabel": "GolbatBitmasks", + "dexLabel": "GolbatPokedexEntry", + "framesLabel": "GolbatFrames", + "front": "golbat", + "frontLabel": "GolbatFrontpic", + "id": "GOLBAT", + "idleLabel": "GolbatAnimationIdle", + "name": "GOLBAT" + }, + "GOLDEEN": { + "animLabel": "GoldeenAnimation", + "back": "goldeen_back", + "backLabel": "GoldeenBackpic", + "bitmaskLabel": "GoldeenBitmasks", + "dexLabel": "GoldeenPokedexEntry", + "framesLabel": "GoldeenFrames", + "front": "goldeen", + "frontLabel": "GoldeenFrontpic", + "id": "GOLDEEN", + "idleLabel": "GoldeenAnimationIdle", + "name": "GOLDEEN" + }, + "GOLDUCK": { + "animLabel": "GolduckAnimation", + "back": "golduck_back", + "backLabel": "GolduckBackpic", + "bitmaskLabel": "GolduckBitmasks", + "dexLabel": "GolduckPokedexEntry", + "framesLabel": "GolduckFrames", + "front": "golduck", + "frontLabel": "GolduckFrontpic", + "id": "GOLDUCK", + "idleLabel": "GolduckAnimationIdle", + "name": "GOLDUCK" + }, + "GOLEM": { + "animLabel": "GolemAnimation", + "back": "golem_back", + "backLabel": "GolemBackpic", + "bitmaskLabel": "GolemBitmasks", + "dexLabel": "GolemPokedexEntry", + "framesLabel": "GolemFrames", + "front": "golem", + "frontLabel": "GolemFrontpic", + "id": "GOLEM", + "idleLabel": "GolemAnimationIdle", + "name": "GOLEM" + }, + "GRANBULL": { + "animLabel": "GranbullAnimation", + "back": "granbull_back", + "backLabel": "GranbullBackpic", + "bitmaskLabel": "GranbullBitmasks", + "dexLabel": "GranbullPokedexEntry", + "framesLabel": "GranbullFrames", + "front": "granbull", + "frontLabel": "GranbullFrontpic", + "id": "GRANBULL", + "idleLabel": "GranbullAnimationIdle", + "name": "GRANBULL" + }, + "GRAVELER": { + "animLabel": "GravelerAnimation", + "back": "graveler_back", + "backLabel": "GravelerBackpic", + "bitmaskLabel": "GravelerBitmasks", + "dexLabel": "GravelerPokedexEntry", + "framesLabel": "GravelerFrames", + "front": "graveler", + "frontLabel": "GravelerFrontpic", + "id": "GRAVELER", + "idleLabel": "GravelerAnimationIdle", + "name": "GRAVELER" + }, + "GRIMER": { + "animLabel": "GrimerAnimation", + "back": "grimer_back", + "backLabel": "GrimerBackpic", + "bitmaskLabel": "GrimerBitmasks", + "dexLabel": "GrimerPokedexEntry", + "framesLabel": "GrimerFrames", + "front": "grimer", + "frontLabel": "GrimerFrontpic", + "id": "GRIMER", + "idleLabel": "GrimerAnimationIdle", + "name": "GRIMER" + }, + "GROWLITHE": { + "animLabel": "GrowlitheAnimation", + "back": "growlithe_back", + "backLabel": "GrowlitheBackpic", + "bitmaskLabel": "GrowlitheBitmasks", + "dexLabel": "GrowlithePokedexEntry", + "framesLabel": "GrowlitheFrames", + "front": "growlithe", + "frontLabel": "GrowlitheFrontpic", + "id": "GROWLITHE", + "idleLabel": "GrowlitheAnimationIdle", + "name": "GROWLITHE" + }, + "GYARADOS": { + "animLabel": "GyaradosAnimation", + "back": "gyarados_back", + "backLabel": "GyaradosBackpic", + "bitmaskLabel": "GyaradosBitmasks", + "dexLabel": "GyaradosPokedexEntry", + "framesLabel": "GyaradosFrames", + "front": "gyarados", + "frontLabel": "GyaradosFrontpic", + "id": "GYARADOS", + "idleLabel": "GyaradosAnimationIdle", + "name": "GYARADOS" + }, + "HAUNTER": { + "animLabel": "HaunterAnimation", + "back": "haunter_back", + "backLabel": "HaunterBackpic", + "bitmaskLabel": "HaunterBitmasks", + "dexLabel": "HaunterPokedexEntry", + "framesLabel": "HaunterFrames", + "front": "haunter", + "frontLabel": "HaunterFrontpic", + "id": "HAUNTER", + "idleLabel": "HaunterAnimationIdle", + "name": "HAUNTER" + }, + "HERACROSS": { + "animLabel": "HeracrossAnimation", + "back": "heracross_back", + "backLabel": "HeracrossBackpic", + "bitmaskLabel": "HeracrossBitmasks", + "dexLabel": "HeracrossPokedexEntry", + "framesLabel": "HeracrossFrames", + "front": "heracross", + "frontLabel": "HeracrossFrontpic", + "id": "HERACROSS", + "idleLabel": "HeracrossAnimationIdle", + "name": "HERACROSS" + }, + "HITMONCHAN": { + "animLabel": "HitmonchanAnimation", + "back": "hitmonchan_back", + "backLabel": "HitmonchanBackpic", + "bitmaskLabel": "HitmonchanBitmasks", + "dexLabel": "HitmonchanPokedexEntry", + "framesLabel": "HitmonchanFrames", + "front": "hitmonchan", + "frontLabel": "HitmonchanFrontpic", + "id": "HITMONCHAN", + "idleLabel": "HitmonchanAnimationIdle", + "name": "HITMONCHAN" + }, + "HITMONLEE": { + "animLabel": "HitmonleeAnimation", + "back": "hitmonlee_back", + "backLabel": "HitmonleeBackpic", + "bitmaskLabel": "HitmonleeBitmasks", + "dexLabel": "HitmonleePokedexEntry", + "framesLabel": "HitmonleeFrames", + "front": "hitmonlee", + "frontLabel": "HitmonleeFrontpic", + "id": "HITMONLEE", + "idleLabel": "HitmonleeAnimationIdle", + "name": "HITMONLEE" + }, + "HITMONTOP": { + "animLabel": "HitmontopAnimation", + "back": "hitmontop_back", + "backLabel": "HitmontopBackpic", + "bitmaskLabel": "HitmontopBitmasks", + "dexLabel": "HitmontopPokedexEntry", + "framesLabel": "HitmontopFrames", + "front": "hitmontop", + "frontLabel": "HitmontopFrontpic", + "id": "HITMONTOP", + "idleLabel": "HitmontopAnimationIdle", + "name": "HITMONTOP" + }, + "HOOTHOOT": { + "animLabel": "HoothootAnimation", + "back": "hoothoot_back", + "backLabel": "HoothootBackpic", + "bitmaskLabel": "HoothootBitmasks", + "dexLabel": "HoothootPokedexEntry", + "framesLabel": "HoothootFrames", + "front": "hoothoot", + "frontLabel": "HoothootFrontpic", + "id": "HOOTHOOT", + "idleLabel": "HoothootAnimationIdle", + "name": "HOOTHOOT" + }, + "HOPPIP": { + "animLabel": "HoppipAnimation", + "back": "hoppip_back", + "backLabel": "HoppipBackpic", + "bitmaskLabel": "HoppipBitmasks", + "dexLabel": "HoppipPokedexEntry", + "framesLabel": "HoppipFrames", + "front": "hoppip", + "frontLabel": "HoppipFrontpic", + "id": "HOPPIP", + "idleLabel": "HoppipAnimationIdle", + "name": "HOPPIP" + }, + "HORSEA": { + "animLabel": "HorseaAnimation", + "back": "horsea_back", + "backLabel": "HorseaBackpic", + "bitmaskLabel": "HorseaBitmasks", + "dexLabel": "HorseaPokedexEntry", + "framesLabel": "HorseaFrames", + "front": "horsea", + "frontLabel": "HorseaFrontpic", + "id": "HORSEA", + "idleLabel": "HorseaAnimationIdle", + "name": "HORSEA" + }, + "HOUNDOOM": { + "animLabel": "HoundoomAnimation", + "back": "houndoom_back", + "backLabel": "HoundoomBackpic", + "bitmaskLabel": "HoundoomBitmasks", + "dexLabel": "HoundoomPokedexEntry", + "framesLabel": "HoundoomFrames", + "front": "houndoom", + "frontLabel": "HoundoomFrontpic", + "id": "HOUNDOOM", + "idleLabel": "HoundoomAnimationIdle", + "name": "HOUNDOOM" + }, + "HOUNDOUR": { + "animLabel": "HoundourAnimation", + "back": "houndour_back", + "backLabel": "HoundourBackpic", + "bitmaskLabel": "HoundourBitmasks", + "dexLabel": "HoundourPokedexEntry", + "framesLabel": "HoundourFrames", + "front": "houndour", + "frontLabel": "HoundourFrontpic", + "id": "HOUNDOUR", + "idleLabel": "HoundourAnimationIdle", + "name": "HOUNDOUR" + }, + "HO_OH": { + "animLabel": "HoOhAnimation", + "back": "hooh_back", + "backLabel": "HoOhBackpic", + "bitmaskLabel": "HoOhBitmasks", + "dexLabel": "HoOhPokedexEntry", + "framesLabel": "HoOhFrames", + "front": "hooh", + "frontLabel": "HoOhFrontpic", + "id": "HO_OH", + "idleLabel": "HoOhAnimationIdle", + "name": "HO-OH" + }, + "HYPNO": { + "animLabel": "HypnoAnimation", + "back": "hypno_back", + "backLabel": "HypnoBackpic", + "bitmaskLabel": "HypnoBitmasks", + "dexLabel": "HypnoPokedexEntry", + "framesLabel": "HypnoFrames", + "front": "hypno", + "frontLabel": "HypnoFrontpic", + "id": "HYPNO", + "idleLabel": "HypnoAnimationIdle", + "name": "HYPNO" + }, + "IGGLYBUFF": { + "animLabel": "IgglybuffAnimation", + "back": "igglybuff_back", + "backLabel": "IgglybuffBackpic", + "bitmaskLabel": "IgglybuffBitmasks", + "dexLabel": "IgglybuffPokedexEntry", + "framesLabel": "IgglybuffFrames", + "front": "igglybuff", + "frontLabel": "IgglybuffFrontpic", + "id": "IGGLYBUFF", + "idleLabel": "IgglybuffAnimationIdle", + "name": "IGGLYBUFF" + }, + "IVYSAUR": { + "animLabel": "IvysaurAnimation", + "back": "ivysaur_back", + "backLabel": "IvysaurBackpic", + "bitmaskLabel": "IvysaurBitmasks", + "dexLabel": "IvysaurPokedexEntry", + "framesLabel": "IvysaurFrames", + "front": "ivysaur", + "frontLabel": "IvysaurFrontpic", + "id": "IVYSAUR", + "idleLabel": "IvysaurAnimationIdle", + "name": "IVYSAUR" + }, + "JIGGLYPUFF": { + "animLabel": "JigglypuffAnimation", + "back": "jigglypuff_back", + "backLabel": "JigglypuffBackpic", + "bitmaskLabel": "JigglypuffBitmasks", + "dexLabel": "JigglypuffPokedexEntry", + "framesLabel": "JigglypuffFrames", + "front": "jigglypuff", + "frontLabel": "JigglypuffFrontpic", + "id": "JIGGLYPUFF", + "idleLabel": "JigglypuffAnimationIdle", + "name": "JIGGLYPUFF" + }, + "JOLTEON": { + "animLabel": "JolteonAnimation", + "back": "jolteon_back", + "backLabel": "JolteonBackpic", + "bitmaskLabel": "JolteonBitmasks", + "dexLabel": "JolteonPokedexEntry", + "framesLabel": "JolteonFrames", + "front": "jolteon", + "frontLabel": "JolteonFrontpic", + "id": "JOLTEON", + "idleLabel": "JolteonAnimationIdle", + "name": "JOLTEON" + }, + "JUMPLUFF": { + "animLabel": "JumpluffAnimation", + "back": "jumpluff_back", + "backLabel": "JumpluffBackpic", + "bitmaskLabel": "JumpluffBitmasks", + "dexLabel": "JumpluffPokedexEntry", + "framesLabel": "JumpluffFrames", + "front": "jumpluff", + "frontLabel": "JumpluffFrontpic", + "id": "JUMPLUFF", + "idleLabel": "JumpluffAnimationIdle", + "name": "JUMPLUFF" + }, + "JYNX": { + "animLabel": "JynxAnimation", + "back": "jynx_back", + "backLabel": "JynxBackpic", + "bitmaskLabel": "JynxBitmasks", + "dexLabel": "JynxPokedexEntry", + "framesLabel": "JynxFrames", + "front": "jynx", + "frontLabel": "JynxFrontpic", + "id": "JYNX", + "idleLabel": "JynxAnimationIdle", + "name": "JYNX" + }, + "KABUTO": { + "animLabel": "KabutoAnimation", + "back": "kabuto_back", + "backLabel": "KabutoBackpic", + "bitmaskLabel": "KabutoBitmasks", + "dexLabel": "KabutoPokedexEntry", + "framesLabel": "KabutoFrames", + "front": "kabuto", + "frontLabel": "KabutoFrontpic", + "id": "KABUTO", + "idleLabel": "KabutoAnimationIdle", + "name": "KABUTO" + }, + "KABUTOPS": { + "animLabel": "KabutopsAnimation", + "back": "kabutops_back", + "backLabel": "KabutopsBackpic", + "bitmaskLabel": "KabutopsBitmasks", + "dexLabel": "KabutopsPokedexEntry", + "framesLabel": "KabutopsFrames", + "front": "kabutops", + "frontLabel": "KabutopsFrontpic", + "id": "KABUTOPS", + "idleLabel": "KabutopsAnimationIdle", + "name": "KABUTOPS" + }, + "KADABRA": { + "animLabel": "KadabraAnimation", + "back": "kadabra_back", + "backLabel": "KadabraBackpic", + "bitmaskLabel": "KadabraBitmasks", + "dexLabel": "KadabraPokedexEntry", + "framesLabel": "KadabraFrames", + "front": "kadabra", + "frontLabel": "KadabraFrontpic", + "id": "KADABRA", + "idleLabel": "KadabraAnimationIdle", + "name": "KADABRA" + }, + "KAKUNA": { + "animLabel": "KakunaAnimation", + "back": "kakuna_back", + "backLabel": "KakunaBackpic", + "bitmaskLabel": "KakunaBitmasks", + "dexLabel": "KakunaPokedexEntry", + "framesLabel": "KakunaFrames", + "front": "kakuna", + "frontLabel": "KakunaFrontpic", + "id": "KAKUNA", + "idleLabel": "KakunaAnimationIdle", + "name": "KAKUNA" + }, + "KANGASKHAN": { + "animLabel": "KangaskhanAnimation", + "back": "kangaskhan_back", + "backLabel": "KangaskhanBackpic", + "bitmaskLabel": "KangaskhanBitmasks", + "dexLabel": "KangaskhanPokedexEntry", + "framesLabel": "KangaskhanFrames", + "front": "kangaskhan", + "frontLabel": "KangaskhanFrontpic", + "id": "KANGASKHAN", + "idleLabel": "KangaskhanAnimationIdle", + "name": "KANGASKHAN" + }, + "KINGDRA": { + "animLabel": "KingdraAnimation", + "back": "kingdra_back", + "backLabel": "KingdraBackpic", + "bitmaskLabel": "KingdraBitmasks", + "dexLabel": "KingdraPokedexEntry", + "framesLabel": "KingdraFrames", + "front": "kingdra", + "frontLabel": "KingdraFrontpic", + "id": "KINGDRA", + "idleLabel": "KingdraAnimationIdle", + "name": "KINGDRA" + }, + "KINGLER": { + "animLabel": "KinglerAnimation", + "back": "kingler_back", + "backLabel": "KinglerBackpic", + "bitmaskLabel": "KinglerBitmasks", + "dexLabel": "KinglerPokedexEntry", + "framesLabel": "KinglerFrames", + "front": "kingler", + "frontLabel": "KinglerFrontpic", + "id": "KINGLER", + "idleLabel": "KinglerAnimationIdle", + "name": "KINGLER" + }, + "KOFFING": { + "animLabel": "KoffingAnimation", + "back": "koffing_back", + "backLabel": "KoffingBackpic", + "bitmaskLabel": "KoffingBitmasks", + "dexLabel": "KoffingPokedexEntry", + "framesLabel": "KoffingFrames", + "front": "koffing", + "frontLabel": "KoffingFrontpic", + "id": "KOFFING", + "idleLabel": "KoffingAnimationIdle", + "name": "KOFFING" + }, + "KRABBY": { + "animLabel": "KrabbyAnimation", + "back": "krabby_back", + "backLabel": "KrabbyBackpic", + "bitmaskLabel": "KrabbyBitmasks", + "dexLabel": "KrabbyPokedexEntry", + "framesLabel": "KrabbyFrames", + "front": "krabby", + "frontLabel": "KrabbyFrontpic", + "id": "KRABBY", + "idleLabel": "KrabbyAnimationIdle", + "name": "KRABBY" + }, + "LANTURN": { + "animLabel": "LanturnAnimation", + "back": "lanturn_back", + "backLabel": "LanturnBackpic", + "bitmaskLabel": "LanturnBitmasks", + "dexLabel": "LanturnPokedexEntry", + "framesLabel": "LanturnFrames", + "front": "lanturn", + "frontLabel": "LanturnFrontpic", + "id": "LANTURN", + "idleLabel": "LanturnAnimationIdle", + "name": "LANTURN" + }, + "LAPRAS": { + "animLabel": "LaprasAnimation", + "back": "lapras_back", + "backLabel": "LaprasBackpic", + "bitmaskLabel": "LaprasBitmasks", + "dexLabel": "LaprasPokedexEntry", + "framesLabel": "LaprasFrames", + "front": "lapras", + "frontLabel": "LaprasFrontpic", + "id": "LAPRAS", + "idleLabel": "LaprasAnimationIdle", + "name": "LAPRAS" + }, + "LARVITAR": { + "animLabel": "LarvitarAnimation", + "back": "larvitar_back", + "backLabel": "LarvitarBackpic", + "bitmaskLabel": "LarvitarBitmasks", + "dexLabel": "LarvitarPokedexEntry", + "framesLabel": "LarvitarFrames", + "front": "larvitar", + "frontLabel": "LarvitarFrontpic", + "id": "LARVITAR", + "idleLabel": "LarvitarAnimationIdle", + "name": "LARVITAR" + }, + "LEDIAN": { + "animLabel": "LedianAnimation", + "back": "ledian_back", + "backLabel": "LedianBackpic", + "bitmaskLabel": "LedianBitmasks", + "dexLabel": "LedianPokedexEntry", + "framesLabel": "LedianFrames", + "front": "ledian", + "frontLabel": "LedianFrontpic", + "id": "LEDIAN", + "idleLabel": "LedianAnimationIdle", + "name": "LEDIAN" + }, + "LEDYBA": { + "animLabel": "LedybaAnimation", + "back": "ledyba_back", + "backLabel": "LedybaBackpic", + "bitmaskLabel": "LedybaBitmasks", + "dexLabel": "LedybaPokedexEntry", + "framesLabel": "LedybaFrames", + "front": "ledyba", + "frontLabel": "LedybaFrontpic", + "id": "LEDYBA", + "idleLabel": "LedybaAnimationIdle", + "name": "LEDYBA" + }, + "LICKITUNG": { + "animLabel": "LickitungAnimation", + "back": "lickitung_back", + "backLabel": "LickitungBackpic", + "bitmaskLabel": "LickitungBitmasks", + "dexLabel": "LickitungPokedexEntry", + "framesLabel": "LickitungFrames", + "front": "lickitung", + "frontLabel": "LickitungFrontpic", + "id": "LICKITUNG", + "idleLabel": "LickitungAnimationIdle", + "name": "LICKITUNG" + }, + "LUGIA": { + "animLabel": "LugiaAnimation", + "back": "lugia_back", + "backLabel": "LugiaBackpic", + "bitmaskLabel": "LugiaBitmasks", + "dexLabel": "LugiaPokedexEntry", + "framesLabel": "LugiaFrames", + "front": "lugia", + "frontLabel": "LugiaFrontpic", + "id": "LUGIA", + "idleLabel": "LugiaAnimationIdle", + "name": "LUGIA" + }, + "MACHAMP": { + "animLabel": "MachampAnimation", + "back": "machamp_back", + "backLabel": "MachampBackpic", + "bitmaskLabel": "MachampBitmasks", + "dexLabel": "MachampPokedexEntry", + "framesLabel": "MachampFrames", + "front": "machamp", + "frontLabel": "MachampFrontpic", + "id": "MACHAMP", + "idleLabel": "MachampAnimationIdle", + "name": "MACHAMP" + }, + "MACHOKE": { + "animLabel": "MachokeAnimation", + "back": "machoke_back", + "backLabel": "MachokeBackpic", + "bitmaskLabel": "MachokeBitmasks", + "dexLabel": "MachokePokedexEntry", + "framesLabel": "MachokeFrames", + "front": "machoke", + "frontLabel": "MachokeFrontpic", + "id": "MACHOKE", + "idleLabel": "MachokeAnimationIdle", + "name": "MACHOKE" + }, + "MACHOP": { + "animLabel": "MachopAnimation", + "back": "machop_back", + "backLabel": "MachopBackpic", + "bitmaskLabel": "MachopBitmasks", + "dexLabel": "MachopPokedexEntry", + "framesLabel": "MachopFrames", + "front": "machop", + "frontLabel": "MachopFrontpic", + "id": "MACHOP", + "idleLabel": "MachopAnimationIdle", + "name": "MACHOP" + }, + "MAGBY": { + "animLabel": "MagbyAnimation", + "back": "magby_back", + "backLabel": "MagbyBackpic", + "bitmaskLabel": "MagbyBitmasks", + "dexLabel": "MagbyPokedexEntry", + "framesLabel": "MagbyFrames", + "front": "magby", + "frontLabel": "MagbyFrontpic", + "id": "MAGBY", + "idleLabel": "MagbyAnimationIdle", + "name": "MAGBY" + }, + "MAGCARGO": { + "animLabel": "MagcargoAnimation", + "back": "magcargo_back", + "backLabel": "MagcargoBackpic", + "bitmaskLabel": "MagcargoBitmasks", + "dexLabel": "MagcargoPokedexEntry", + "framesLabel": "MagcargoFrames", + "front": "magcargo", + "frontLabel": "MagcargoFrontpic", + "id": "MAGCARGO", + "idleLabel": "MagcargoAnimationIdle", + "name": "MAGCARGO" + }, + "MAGIKARP": { + "animLabel": "MagikarpAnimation", + "back": "magikarp_back", + "backLabel": "MagikarpBackpic", + "bitmaskLabel": "MagikarpBitmasks", + "dexLabel": "MagikarpPokedexEntry", + "framesLabel": "MagikarpFrames", + "front": "magikarp", + "frontLabel": "MagikarpFrontpic", + "id": "MAGIKARP", + "idleLabel": "MagikarpAnimationIdle", + "name": "MAGIKARP" + }, + "MAGMAR": { + "animLabel": "MagmarAnimation", + "back": "magmar_back", + "backLabel": "MagmarBackpic", + "bitmaskLabel": "MagmarBitmasks", + "dexLabel": "MagmarPokedexEntry", + "framesLabel": "MagmarFrames", + "front": "magmar", + "frontLabel": "MagmarFrontpic", + "id": "MAGMAR", + "idleLabel": "MagmarAnimationIdle", + "name": "MAGMAR" + }, + "MAGNEMITE": { + "animLabel": "MagnemiteAnimation", + "back": "magnemite_back", + "backLabel": "MagnemiteBackpic", + "bitmaskLabel": "MagnemiteBitmasks", + "dexLabel": "MagnemitePokedexEntry", + "framesLabel": "MagnemiteFrames", + "front": "magnemite", + "frontLabel": "MagnemiteFrontpic", + "id": "MAGNEMITE", + "idleLabel": "MagnemiteAnimationIdle", + "name": "MAGNEMITE" + }, + "MAGNETON": { + "animLabel": "MagnetonAnimation", + "back": "magneton_back", + "backLabel": "MagnetonBackpic", + "bitmaskLabel": "MagnetonBitmasks", + "dexLabel": "MagnetonPokedexEntry", + "framesLabel": "MagnetonFrames", + "front": "magneton", + "frontLabel": "MagnetonFrontpic", + "id": "MAGNETON", + "idleLabel": "MagnetonAnimationIdle", + "name": "MAGNETON" + }, + "MANKEY": { + "animLabel": "MankeyAnimation", + "back": "mankey_back", + "backLabel": "MankeyBackpic", + "bitmaskLabel": "MankeyBitmasks", + "dexLabel": "MankeyPokedexEntry", + "framesLabel": "MankeyFrames", + "front": "mankey", + "frontLabel": "MankeyFrontpic", + "id": "MANKEY", + "idleLabel": "MankeyAnimationIdle", + "name": "MANKEY" + }, + "MANTINE": { + "animLabel": "MantineAnimation", + "back": "mantine_back", + "backLabel": "MantineBackpic", + "bitmaskLabel": "MantineBitmasks", + "dexLabel": "MantinePokedexEntry", + "framesLabel": "MantineFrames", + "front": "mantine", + "frontLabel": "MantineFrontpic", + "id": "MANTINE", + "idleLabel": "MantineAnimationIdle", + "name": "MANTINE" + }, + "MAREEP": { + "animLabel": "MareepAnimation", + "back": "mareep_back", + "backLabel": "MareepBackpic", + "bitmaskLabel": "MareepBitmasks", + "dexLabel": "MareepPokedexEntry", + "framesLabel": "MareepFrames", + "front": "mareep", + "frontLabel": "MareepFrontpic", + "id": "MAREEP", + "idleLabel": "MareepAnimationIdle", + "name": "MAREEP" + }, + "MARILL": { + "animLabel": "MarillAnimation", + "back": "marill_back", + "backLabel": "MarillBackpic", + "bitmaskLabel": "MarillBitmasks", + "dexLabel": "MarillPokedexEntry", + "framesLabel": "MarillFrames", + "front": "marill", + "frontLabel": "MarillFrontpic", + "id": "MARILL", + "idleLabel": "MarillAnimationIdle", + "name": "MARILL" + }, + "MAROWAK": { + "animLabel": "MarowakAnimation", + "back": "marowak_back", + "backLabel": "MarowakBackpic", + "bitmaskLabel": "MarowakBitmasks", + "dexLabel": "MarowakPokedexEntry", + "framesLabel": "MarowakFrames", + "front": "marowak", + "frontLabel": "MarowakFrontpic", + "id": "MAROWAK", + "idleLabel": "MarowakAnimationIdle", + "name": "MAROWAK" + }, + "MEGANIUM": { + "animLabel": "MeganiumAnimation", + "back": "meganium_back", + "backLabel": "MeganiumBackpic", + "bitmaskLabel": "MeganiumBitmasks", + "dexLabel": "MeganiumPokedexEntry", + "framesLabel": "MeganiumFrames", + "front": "meganium", + "frontLabel": "MeganiumFrontpic", + "id": "MEGANIUM", + "idleLabel": "MeganiumAnimationIdle", + "name": "MEGANIUM" + }, + "MEOWTH": { + "animLabel": "MeowthAnimation", + "back": "meowth_back", + "backLabel": "MeowthBackpic", + "bitmaskLabel": "MeowthBitmasks", + "dexLabel": "MeowthPokedexEntry", + "framesLabel": "MeowthFrames", + "front": "meowth", + "frontLabel": "MeowthFrontpic", + "id": "MEOWTH", + "idleLabel": "MeowthAnimationIdle", + "name": "MEOWTH" + }, + "METAPOD": { + "animLabel": "MetapodAnimation", + "back": "metapod_back", + "backLabel": "MetapodBackpic", + "bitmaskLabel": "MetapodBitmasks", + "dexLabel": "MetapodPokedexEntry", + "framesLabel": "MetapodFrames", + "front": "metapod", + "frontLabel": "MetapodFrontpic", + "id": "METAPOD", + "idleLabel": "MetapodAnimationIdle", + "name": "METAPOD" + }, + "MEW": { + "animLabel": "MewAnimation", + "back": "mew_back", + "backLabel": "MewBackpic", + "bitmaskLabel": "MewBitmasks", + "dexLabel": "MewPokedexEntry", + "framesLabel": "MewFrames", + "front": "mew", + "frontLabel": "MewFrontpic", + "id": "MEW", + "idleLabel": "MewAnimationIdle", + "name": "MEW" + }, + "MEWTWO": { + "animLabel": "MewtwoAnimation", + "back": "mewtwo_back", + "backLabel": "MewtwoBackpic", + "bitmaskLabel": "MewtwoBitmasks", + "dexLabel": "MewtwoPokedexEntry", + "framesLabel": "MewtwoFrames", + "front": "mewtwo", + "frontLabel": "MewtwoFrontpic", + "id": "MEWTWO", + "idleLabel": "MewtwoAnimationIdle", + "name": "MEWTWO" + }, + "MILTANK": { + "animLabel": "MiltankAnimation", + "back": "miltank_back", + "backLabel": "MiltankBackpic", + "bitmaskLabel": "MiltankBitmasks", + "dexLabel": "MiltankPokedexEntry", + "framesLabel": "MiltankFrames", + "front": "miltank", + "frontLabel": "MiltankFrontpic", + "id": "MILTANK", + "idleLabel": "MiltankAnimationIdle", + "name": "MILTANK" + }, + "MISDREAVUS": { + "animLabel": "MisdreavusAnimation", + "back": "misdreavus_back", + "backLabel": "MisdreavusBackpic", + "bitmaskLabel": "MisdreavusBitmasks", + "dexLabel": "MisdreavusPokedexEntry", + "framesLabel": "MisdreavusFrames", + "front": "misdreavus", + "frontLabel": "MisdreavusFrontpic", + "id": "MISDREAVUS", + "idleLabel": "MisdreavusAnimationIdle", + "name": "MISDREAVUS" + }, + "MOLTRES": { + "animLabel": "MoltresAnimation", + "back": "moltres_back", + "backLabel": "MoltresBackpic", + "bitmaskLabel": "MoltresBitmasks", + "dexLabel": "MoltresPokedexEntry", + "framesLabel": "MoltresFrames", + "front": "moltres", + "frontLabel": "MoltresFrontpic", + "id": "MOLTRES", + "idleLabel": "MoltresAnimationIdle", + "name": "MOLTRES" + }, + "MR__MIME": { + "animLabel": "MrMimeAnimation", + "back": "mrmime_back", + "backLabel": "MrMimeBackpic", + "bitmaskLabel": "MrMimeBitmasks", + "dexLabel": "MrMimePokedexEntry", + "framesLabel": "MrMimeFrames", + "front": "mrmime", + "frontLabel": "MrMimeFrontpic", + "id": "MR__MIME", + "idleLabel": "MrMimeAnimationIdle", + "name": "MR.MIME" + }, + "MUK": { + "animLabel": "MukAnimation", + "back": "muk_back", + "backLabel": "MukBackpic", + "bitmaskLabel": "MukBitmasks", + "dexLabel": "MukPokedexEntry", + "framesLabel": "MukFrames", + "front": "muk", + "frontLabel": "MukFrontpic", + "id": "MUK", + "idleLabel": "MukAnimationIdle", + "name": "MUK" + }, + "MURKROW": { + "animLabel": "MurkrowAnimation", + "back": "murkrow_back", + "backLabel": "MurkrowBackpic", + "bitmaskLabel": "MurkrowBitmasks", + "dexLabel": "MurkrowPokedexEntry", + "framesLabel": "MurkrowFrames", + "front": "murkrow", + "frontLabel": "MurkrowFrontpic", + "id": "MURKROW", + "idleLabel": "MurkrowAnimationIdle", + "name": "MURKROW" + }, + "NATU": { + "animLabel": "NatuAnimation", + "back": "natu_back", + "backLabel": "NatuBackpic", + "bitmaskLabel": "NatuBitmasks", + "dexLabel": "NatuPokedexEntry", + "framesLabel": "NatuFrames", + "front": "natu", + "frontLabel": "NatuFrontpic", + "id": "NATU", + "idleLabel": "NatuAnimationIdle", + "name": "NATU" + }, + "NIDOKING": { + "animLabel": "NidokingAnimation", + "back": "nidoking_back", + "backLabel": "NidokingBackpic", + "bitmaskLabel": "NidokingBitmasks", + "dexLabel": "NidokingPokedexEntry", + "framesLabel": "NidokingFrames", + "front": "nidoking", + "frontLabel": "NidokingFrontpic", + "id": "NIDOKING", + "idleLabel": "NidokingAnimationIdle", + "name": "NIDOKING" + }, + "NIDOQUEEN": { + "animLabel": "NidoqueenAnimation", + "back": "nidoqueen_back", + "backLabel": "NidoqueenBackpic", + "bitmaskLabel": "NidoqueenBitmasks", + "dexLabel": "NidoqueenPokedexEntry", + "framesLabel": "NidoqueenFrames", + "front": "nidoqueen", + "frontLabel": "NidoqueenFrontpic", + "id": "NIDOQUEEN", + "idleLabel": "NidoqueenAnimationIdle", + "name": "NIDOQUEEN" + }, + "NIDORAN_F": { + "animLabel": "NidoranFAnimation", + "back": "nidoranf_back", + "backLabel": "NidoranFBackpic", + "bitmaskLabel": "NidoranFBitmasks", + "dexLabel": "NidoranFPokedexEntry", + "framesLabel": "NidoranFFrames", + "front": "nidoranf", + "frontLabel": "NidoranFFrontpic", + "id": "NIDORAN_F", + "idleLabel": "NidoranFAnimationIdle", + "name": "NIDORAN♀" + }, + "NIDORAN_M": { + "animLabel": "NidoranMAnimation", + "back": "nidoranm_back", + "backLabel": "NidoranMBackpic", + "bitmaskLabel": "NidoranMBitmasks", + "dexLabel": "NidoranMPokedexEntry", + "framesLabel": "NidoranMFrames", + "front": "nidoranm", + "frontLabel": "NidoranMFrontpic", + "id": "NIDORAN_M", + "idleLabel": "NidoranMAnimationIdle", + "name": "NIDORAN♂" + }, + "NIDORINA": { + "animLabel": "NidorinaAnimation", + "back": "nidorina_back", + "backLabel": "NidorinaBackpic", + "bitmaskLabel": "NidorinaBitmasks", + "dexLabel": "NidorinaPokedexEntry", + "framesLabel": "NidorinaFrames", + "front": "nidorina", + "frontLabel": "NidorinaFrontpic", + "id": "NIDORINA", + "idleLabel": "NidorinaAnimationIdle", + "name": "NIDORINA" + }, + "NIDORINO": { + "animLabel": "NidorinoAnimation", + "back": "nidorino_back", + "backLabel": "NidorinoBackpic", + "bitmaskLabel": "NidorinoBitmasks", + "dexLabel": "NidorinoPokedexEntry", + "framesLabel": "NidorinoFrames", + "front": "nidorino", + "frontLabel": "NidorinoFrontpic", + "id": "NIDORINO", + "idleLabel": "NidorinoAnimationIdle", + "name": "NIDORINO" + }, + "NINETALES": { + "animLabel": "NinetalesAnimation", + "back": "ninetales_back", + "backLabel": "NinetalesBackpic", + "bitmaskLabel": "NinetalesBitmasks", + "dexLabel": "NinetalesPokedexEntry", + "framesLabel": "NinetalesFrames", + "front": "ninetales", + "frontLabel": "NinetalesFrontpic", + "id": "NINETALES", + "idleLabel": "NinetalesAnimationIdle", + "name": "NINETALES" + }, + "NOCTOWL": { + "animLabel": "NoctowlAnimation", + "back": "noctowl_back", + "backLabel": "NoctowlBackpic", + "bitmaskLabel": "NoctowlBitmasks", + "dexLabel": "NoctowlPokedexEntry", + "framesLabel": "NoctowlFrames", + "front": "noctowl", + "frontLabel": "NoctowlFrontpic", + "id": "NOCTOWL", + "idleLabel": "NoctowlAnimationIdle", + "name": "NOCTOWL" + }, + "OCTILLERY": { + "animLabel": "OctilleryAnimation", + "back": "octillery_back", + "backLabel": "OctilleryBackpic", + "bitmaskLabel": "OctilleryBitmasks", + "dexLabel": "OctilleryPokedexEntry", + "framesLabel": "OctilleryFrames", + "front": "octillery", + "frontLabel": "OctilleryFrontpic", + "id": "OCTILLERY", + "idleLabel": "OctilleryAnimationIdle", + "name": "OCTILLERY" + }, + "ODDISH": { + "animLabel": "OddishAnimation", + "back": "oddish_back", + "backLabel": "OddishBackpic", + "bitmaskLabel": "OddishBitmasks", + "dexLabel": "OddishPokedexEntry", + "framesLabel": "OddishFrames", + "front": "oddish", + "frontLabel": "OddishFrontpic", + "id": "ODDISH", + "idleLabel": "OddishAnimationIdle", + "name": "ODDISH" + }, + "OMANYTE": { + "animLabel": "OmanyteAnimation", + "back": "omanyte_back", + "backLabel": "OmanyteBackpic", + "bitmaskLabel": "OmanyteBitmasks", + "dexLabel": "OmanytePokedexEntry", + "framesLabel": "OmanyteFrames", + "front": "omanyte", + "frontLabel": "OmanyteFrontpic", + "id": "OMANYTE", + "idleLabel": "OmanyteAnimationIdle", + "name": "OMANYTE" + }, + "OMASTAR": { + "animLabel": "OmastarAnimation", + "back": "omastar_back", + "backLabel": "OmastarBackpic", + "bitmaskLabel": "OmastarBitmasks", + "dexLabel": "OmastarPokedexEntry", + "framesLabel": "OmastarFrames", + "front": "omastar", + "frontLabel": "OmastarFrontpic", + "id": "OMASTAR", + "idleLabel": "OmastarAnimationIdle", + "name": "OMASTAR" + }, + "ONIX": { + "animLabel": "OnixAnimation", + "back": "onix_back", + "backLabel": "OnixBackpic", + "bitmaskLabel": "OnixBitmasks", + "dexLabel": "OnixPokedexEntry", + "framesLabel": "OnixFrames", + "front": "onix", + "frontLabel": "OnixFrontpic", + "id": "ONIX", + "idleLabel": "OnixAnimationIdle", + "name": "ONIX" + }, + "PARAS": { + "animLabel": "ParasAnimation", + "back": "paras_back", + "backLabel": "ParasBackpic", + "bitmaskLabel": "ParasBitmasks", + "dexLabel": "ParasPokedexEntry", + "framesLabel": "ParasFrames", + "front": "paras", + "frontLabel": "ParasFrontpic", + "id": "PARAS", + "idleLabel": "ParasAnimationIdle", + "name": "PARAS" + }, + "PARASECT": { + "animLabel": "ParasectAnimation", + "back": "parasect_back", + "backLabel": "ParasectBackpic", + "bitmaskLabel": "ParasectBitmasks", + "dexLabel": "ParasectPokedexEntry", + "framesLabel": "ParasectFrames", + "front": "parasect", + "frontLabel": "ParasectFrontpic", + "id": "PARASECT", + "idleLabel": "ParasectAnimationIdle", + "name": "PARASECT" + }, + "PERSIAN": { + "animLabel": "PersianAnimation", + "back": "persian_back", + "backLabel": "PersianBackpic", + "bitmaskLabel": "PersianBitmasks", + "dexLabel": "PersianPokedexEntry", + "framesLabel": "PersianFrames", + "front": "persian", + "frontLabel": "PersianFrontpic", + "id": "PERSIAN", + "idleLabel": "PersianAnimationIdle", + "name": "PERSIAN" + }, + "PHANPY": { + "animLabel": "PhanpyAnimation", + "back": "phanpy_back", + "backLabel": "PhanpyBackpic", + "bitmaskLabel": "PhanpyBitmasks", + "dexLabel": "PhanpyPokedexEntry", + "framesLabel": "PhanpyFrames", + "front": "phanpy", + "frontLabel": "PhanpyFrontpic", + "id": "PHANPY", + "idleLabel": "PhanpyAnimationIdle", + "name": "PHANPY" + }, + "PICHU": { + "animLabel": "PichuAnimation", + "back": "pichu_back", + "backLabel": "PichuBackpic", + "bitmaskLabel": "PichuBitmasks", + "dexLabel": "PichuPokedexEntry", + "framesLabel": "PichuFrames", + "front": "pichu", + "frontLabel": "PichuFrontpic", + "id": "PICHU", + "idleLabel": "PichuAnimationIdle", + "name": "PICHU" + }, + "PIDGEOT": { + "animLabel": "PidgeotAnimation", + "back": "pidgeot_back", + "backLabel": "PidgeotBackpic", + "bitmaskLabel": "PidgeotBitmasks", + "dexLabel": "PidgeotPokedexEntry", + "framesLabel": "PidgeotFrames", + "front": "pidgeot", + "frontLabel": "PidgeotFrontpic", + "id": "PIDGEOT", + "idleLabel": "PidgeotAnimationIdle", + "name": "PIDGEOT" + }, + "PIDGEOTTO": { + "animLabel": "PidgeottoAnimation", + "back": "pidgeotto_back", + "backLabel": "PidgeottoBackpic", + "bitmaskLabel": "PidgeottoBitmasks", + "dexLabel": "PidgeottoPokedexEntry", + "framesLabel": "PidgeottoFrames", + "front": "pidgeotto", + "frontLabel": "PidgeottoFrontpic", + "id": "PIDGEOTTO", + "idleLabel": "PidgeottoAnimationIdle", + "name": "PIDGEOTTO" + }, + "PIDGEY": { + "animLabel": "PidgeyAnimation", + "back": "pidgey_back", + "backLabel": "PidgeyBackpic", + "bitmaskLabel": "PidgeyBitmasks", + "dexLabel": "PidgeyPokedexEntry", + "framesLabel": "PidgeyFrames", + "front": "pidgey", + "frontLabel": "PidgeyFrontpic", + "id": "PIDGEY", + "idleLabel": "PidgeyAnimationIdle", + "name": "PIDGEY" + }, + "PIKACHU": { + "animLabel": "PikachuAnimation", + "back": "pikachu_back", + "backLabel": "PikachuBackpic", + "bitmaskLabel": "PikachuBitmasks", + "dexLabel": "PikachuPokedexEntry", + "framesLabel": "PikachuFrames", + "front": "pikachu", + "frontLabel": "PikachuFrontpic", + "id": "PIKACHU", + "idleLabel": "PikachuAnimationIdle", + "name": "PIKACHU" + }, + "PILOSWINE": { + "animLabel": "PiloswineAnimation", + "back": "piloswine_back", + "backLabel": "PiloswineBackpic", + "bitmaskLabel": "PiloswineBitmasks", + "dexLabel": "PiloswinePokedexEntry", + "framesLabel": "PiloswineFrames", + "front": "piloswine", + "frontLabel": "PiloswineFrontpic", + "id": "PILOSWINE", + "idleLabel": "PiloswineAnimationIdle", + "name": "PILOSWINE" + }, + "PINECO": { + "animLabel": "PinecoAnimation", + "back": "pineco_back", + "backLabel": "PinecoBackpic", + "bitmaskLabel": "PinecoBitmasks", + "dexLabel": "PinecoPokedexEntry", + "framesLabel": "PinecoFrames", + "front": "pineco", + "frontLabel": "PinecoFrontpic", + "id": "PINECO", + "idleLabel": "PinecoAnimationIdle", + "name": "PINECO" + }, + "PINSIR": { + "animLabel": "PinsirAnimation", + "back": "pinsir_back", + "backLabel": "PinsirBackpic", + "bitmaskLabel": "PinsirBitmasks", + "dexLabel": "PinsirPokedexEntry", + "framesLabel": "PinsirFrames", + "front": "pinsir", + "frontLabel": "PinsirFrontpic", + "id": "PINSIR", + "idleLabel": "PinsirAnimationIdle", + "name": "PINSIR" + }, + "POLITOED": { + "animLabel": "PolitoedAnimation", + "back": "politoed_back", + "backLabel": "PolitoedBackpic", + "bitmaskLabel": "PolitoedBitmasks", + "dexLabel": "PolitoedPokedexEntry", + "framesLabel": "PolitoedFrames", + "front": "politoed", + "frontLabel": "PolitoedFrontpic", + "id": "POLITOED", + "idleLabel": "PolitoedAnimationIdle", + "name": "POLITOED" + }, + "POLIWAG": { + "animLabel": "PoliwagAnimation", + "back": "poliwag_back", + "backLabel": "PoliwagBackpic", + "bitmaskLabel": "PoliwagBitmasks", + "dexLabel": "PoliwagPokedexEntry", + "framesLabel": "PoliwagFrames", + "front": "poliwag", + "frontLabel": "PoliwagFrontpic", + "id": "POLIWAG", + "idleLabel": "PoliwagAnimationIdle", + "name": "POLIWAG" + }, + "POLIWHIRL": { + "animLabel": "PoliwhirlAnimation", + "back": "poliwhirl_back", + "backLabel": "PoliwhirlBackpic", + "bitmaskLabel": "PoliwhirlBitmasks", + "dexLabel": "PoliwhirlPokedexEntry", + "framesLabel": "PoliwhirlFrames", + "front": "poliwhirl", + "frontLabel": "PoliwhirlFrontpic", + "id": "POLIWHIRL", + "idleLabel": "PoliwhirlAnimationIdle", + "name": "POLIWHIRL" + }, + "POLIWRATH": { + "animLabel": "PoliwrathAnimation", + "back": "poliwrath_back", + "backLabel": "PoliwrathBackpic", + "bitmaskLabel": "PoliwrathBitmasks", + "dexLabel": "PoliwrathPokedexEntry", + "framesLabel": "PoliwrathFrames", + "front": "poliwrath", + "frontLabel": "PoliwrathFrontpic", + "id": "POLIWRATH", + "idleLabel": "PoliwrathAnimationIdle", + "name": "POLIWRATH" + }, + "PONYTA": { + "animLabel": "PonytaAnimation", + "back": "ponyta_back", + "backLabel": "PonytaBackpic", + "bitmaskLabel": "PonytaBitmasks", + "dexLabel": "PonytaPokedexEntry", + "framesLabel": "PonytaFrames", + "front": "ponyta", + "frontLabel": "PonytaFrontpic", + "id": "PONYTA", + "idleLabel": "PonytaAnimationIdle", + "name": "PONYTA" + }, + "PORYGON": { + "animLabel": "PorygonAnimation", + "back": "porygon_back", + "backLabel": "PorygonBackpic", + "bitmaskLabel": "PorygonBitmasks", + "dexLabel": "PorygonPokedexEntry", + "framesLabel": "PorygonFrames", + "front": "porygon", + "frontLabel": "PorygonFrontpic", + "id": "PORYGON", + "idleLabel": "PorygonAnimationIdle", + "name": "PORYGON" + }, + "PORYGON2": { + "animLabel": "Porygon2Animation", + "back": "porygon2_back", + "backLabel": "Porygon2Backpic", + "bitmaskLabel": "Porygon2Bitmasks", + "dexLabel": "Porygon2PokedexEntry", + "framesLabel": "Porygon2Frames", + "front": "porygon2", + "frontLabel": "Porygon2Frontpic", + "id": "PORYGON2", + "idleLabel": "Porygon2AnimationIdle", + "name": "PORYGON2" + }, + "PRIMEAPE": { + "animLabel": "PrimeapeAnimation", + "back": "primeape_back", + "backLabel": "PrimeapeBackpic", + "bitmaskLabel": "PrimeapeBitmasks", + "dexLabel": "PrimeapePokedexEntry", + "framesLabel": "PrimeapeFrames", + "front": "primeape", + "frontLabel": "PrimeapeFrontpic", + "id": "PRIMEAPE", + "idleLabel": "PrimeapeAnimationIdle", + "name": "PRIMEAPE" + }, + "PSYDUCK": { + "animLabel": "PsyduckAnimation", + "back": "psyduck_back", + "backLabel": "PsyduckBackpic", + "bitmaskLabel": "PsyduckBitmasks", + "dexLabel": "PsyduckPokedexEntry", + "framesLabel": "PsyduckFrames", + "front": "psyduck", + "frontLabel": "PsyduckFrontpic", + "id": "PSYDUCK", + "idleLabel": "PsyduckAnimationIdle", + "name": "PSYDUCK" + }, + "PUPITAR": { + "animLabel": "PupitarAnimation", + "back": "pupitar_back", + "backLabel": "PupitarBackpic", + "bitmaskLabel": "PupitarBitmasks", + "dexLabel": "PupitarPokedexEntry", + "framesLabel": "PupitarFrames", + "front": "pupitar", + "frontLabel": "PupitarFrontpic", + "id": "PUPITAR", + "idleLabel": "PupitarAnimationIdle", + "name": "PUPITAR" + }, + "QUAGSIRE": { + "animLabel": "QuagsireAnimation", + "back": "quagsire_back", + "backLabel": "QuagsireBackpic", + "bitmaskLabel": "QuagsireBitmasks", + "dexLabel": "QuagsirePokedexEntry", + "framesLabel": "QuagsireFrames", + "front": "quagsire", + "frontLabel": "QuagsireFrontpic", + "id": "QUAGSIRE", + "idleLabel": "QuagsireAnimationIdle", + "name": "QUAGSIRE" + }, + "QUILAVA": { + "animLabel": "QuilavaAnimation", + "back": "quilava_back", + "backLabel": "QuilavaBackpic", + "bitmaskLabel": "QuilavaBitmasks", + "dexLabel": "QuilavaPokedexEntry", + "framesLabel": "QuilavaFrames", + "front": "quilava", + "frontLabel": "QuilavaFrontpic", + "id": "QUILAVA", + "idleLabel": "QuilavaAnimationIdle", + "name": "QUILAVA" + }, + "QWILFISH": { + "animLabel": "QwilfishAnimation", + "back": "qwilfish_back", + "backLabel": "QwilfishBackpic", + "bitmaskLabel": "QwilfishBitmasks", + "dexLabel": "QwilfishPokedexEntry", + "framesLabel": "QwilfishFrames", + "front": "qwilfish", + "frontLabel": "QwilfishFrontpic", + "id": "QWILFISH", + "idleLabel": "QwilfishAnimationIdle", + "name": "QWILFISH" + }, + "RAICHU": { + "animLabel": "RaichuAnimation", + "back": "raichu_back", + "backLabel": "RaichuBackpic", + "bitmaskLabel": "RaichuBitmasks", + "dexLabel": "RaichuPokedexEntry", + "framesLabel": "RaichuFrames", + "front": "raichu", + "frontLabel": "RaichuFrontpic", + "id": "RAICHU", + "idleLabel": "RaichuAnimationIdle", + "name": "RAICHU" + }, + "RAIKOU": { + "animLabel": "RaikouAnimation", + "back": "raikou_back", + "backLabel": "RaikouBackpic", + "bitmaskLabel": "RaikouBitmasks", + "dexLabel": "RaikouPokedexEntry", + "framesLabel": "RaikouFrames", + "front": "raikou", + "frontLabel": "RaikouFrontpic", + "id": "RAIKOU", + "idleLabel": "RaikouAnimationIdle", + "name": "RAIKOU" + }, + "RAPIDASH": { + "animLabel": "RapidashAnimation", + "back": "rapidash_back", + "backLabel": "RapidashBackpic", + "bitmaskLabel": "RapidashBitmasks", + "dexLabel": "RapidashPokedexEntry", + "framesLabel": "RapidashFrames", + "front": "rapidash", + "frontLabel": "RapidashFrontpic", + "id": "RAPIDASH", + "idleLabel": "RapidashAnimationIdle", + "name": "RAPIDASH" + }, + "RATICATE": { + "animLabel": "RaticateAnimation", + "back": "raticate_back", + "backLabel": "RaticateBackpic", + "bitmaskLabel": "RaticateBitmasks", + "dexLabel": "RaticatePokedexEntry", + "framesLabel": "RaticateFrames", + "front": "raticate", + "frontLabel": "RaticateFrontpic", + "id": "RATICATE", + "idleLabel": "RaticateAnimationIdle", + "name": "RATICATE" + }, + "RATTATA": { + "animLabel": "RattataAnimation", + "back": "rattata_back", + "backLabel": "RattataBackpic", + "bitmaskLabel": "RattataBitmasks", + "dexLabel": "RattataPokedexEntry", + "framesLabel": "RattataFrames", + "front": "rattata", + "frontLabel": "RattataFrontpic", + "id": "RATTATA", + "idleLabel": "RattataAnimationIdle", + "name": "RATTATA" + }, + "REMORAID": { + "animLabel": "RemoraidAnimation", + "back": "remoraid_back", + "backLabel": "RemoraidBackpic", + "bitmaskLabel": "RemoraidBitmasks", + "dexLabel": "RemoraidPokedexEntry", + "framesLabel": "RemoraidFrames", + "front": "remoraid", + "frontLabel": "RemoraidFrontpic", + "id": "REMORAID", + "idleLabel": "RemoraidAnimationIdle", + "name": "REMORAID" + }, + "RHYDON": { + "animLabel": "RhydonAnimation", + "back": "rhydon_back", + "backLabel": "RhydonBackpic", + "bitmaskLabel": "RhydonBitmasks", + "dexLabel": "RhydonPokedexEntry", + "framesLabel": "RhydonFrames", + "front": "rhydon", + "frontLabel": "RhydonFrontpic", + "id": "RHYDON", + "idleLabel": "RhydonAnimationIdle", + "name": "RHYDON" + }, + "RHYHORN": { + "animLabel": "RhyhornAnimation", + "back": "rhyhorn_back", + "backLabel": "RhyhornBackpic", + "bitmaskLabel": "RhyhornBitmasks", + "dexLabel": "RhyhornPokedexEntry", + "framesLabel": "RhyhornFrames", + "front": "rhyhorn", + "frontLabel": "RhyhornFrontpic", + "id": "RHYHORN", + "idleLabel": "RhyhornAnimationIdle", + "name": "RHYHORN" + }, + "SANDSHREW": { + "animLabel": "SandshrewAnimation", + "back": "sandshrew_back", + "backLabel": "SandshrewBackpic", + "bitmaskLabel": "SandshrewBitmasks", + "dexLabel": "SandshrewPokedexEntry", + "framesLabel": "SandshrewFrames", + "front": "sandshrew", + "frontLabel": "SandshrewFrontpic", + "id": "SANDSHREW", + "idleLabel": "SandshrewAnimationIdle", + "name": "SANDSHREW" + }, + "SANDSLASH": { + "animLabel": "SandslashAnimation", + "back": "sandslash_back", + "backLabel": "SandslashBackpic", + "bitmaskLabel": "SandslashBitmasks", + "dexLabel": "SandslashPokedexEntry", + "framesLabel": "SandslashFrames", + "front": "sandslash", + "frontLabel": "SandslashFrontpic", + "id": "SANDSLASH", + "idleLabel": "SandslashAnimationIdle", + "name": "SANDSLASH" + }, + "SCIZOR": { + "animLabel": "ScizorAnimation", + "back": "scizor_back", + "backLabel": "ScizorBackpic", + "bitmaskLabel": "ScizorBitmasks", + "dexLabel": "ScizorPokedexEntry", + "framesLabel": "ScizorFrames", + "front": "scizor", + "frontLabel": "ScizorFrontpic", + "id": "SCIZOR", + "idleLabel": "ScizorAnimationIdle", + "name": "SCIZOR" + }, + "SCYTHER": { + "animLabel": "ScytherAnimation", + "back": "scyther_back", + "backLabel": "ScytherBackpic", + "bitmaskLabel": "ScytherBitmasks", + "dexLabel": "ScytherPokedexEntry", + "framesLabel": "ScytherFrames", + "front": "scyther", + "frontLabel": "ScytherFrontpic", + "id": "SCYTHER", + "idleLabel": "ScytherAnimationIdle", + "name": "SCYTHER" + }, + "SEADRA": { + "animLabel": "SeadraAnimation", + "back": "seadra_back", + "backLabel": "SeadraBackpic", + "bitmaskLabel": "SeadraBitmasks", + "dexLabel": "SeadraPokedexEntry", + "framesLabel": "SeadraFrames", + "front": "seadra", + "frontLabel": "SeadraFrontpic", + "id": "SEADRA", + "idleLabel": "SeadraAnimationIdle", + "name": "SEADRA" + }, + "SEAKING": { + "animLabel": "SeakingAnimation", + "back": "seaking_back", + "backLabel": "SeakingBackpic", + "bitmaskLabel": "SeakingBitmasks", + "dexLabel": "SeakingPokedexEntry", + "framesLabel": "SeakingFrames", + "front": "seaking", + "frontLabel": "SeakingFrontpic", + "id": "SEAKING", + "idleLabel": "SeakingAnimationIdle", + "name": "SEAKING" + }, + "SEEL": { + "animLabel": "SeelAnimation", + "back": "seel_back", + "backLabel": "SeelBackpic", + "bitmaskLabel": "SeelBitmasks", + "dexLabel": "SeelPokedexEntry", + "framesLabel": "SeelFrames", + "front": "seel", + "frontLabel": "SeelFrontpic", + "id": "SEEL", + "idleLabel": "SeelAnimationIdle", + "name": "SEEL" + }, + "SENTRET": { + "animLabel": "SentretAnimation", + "back": "sentret_back", + "backLabel": "SentretBackpic", + "bitmaskLabel": "SentretBitmasks", + "dexLabel": "SentretPokedexEntry", + "framesLabel": "SentretFrames", + "front": "sentret", + "frontLabel": "SentretFrontpic", + "id": "SENTRET", + "idleLabel": "SentretAnimationIdle", + "name": "SENTRET" + }, + "SHELLDER": { + "animLabel": "ShellderAnimation", + "back": "shellder_back", + "backLabel": "ShellderBackpic", + "bitmaskLabel": "ShellderBitmasks", + "dexLabel": "ShellderPokedexEntry", + "framesLabel": "ShellderFrames", + "front": "shellder", + "frontLabel": "ShellderFrontpic", + "id": "SHELLDER", + "idleLabel": "ShellderAnimationIdle", + "name": "SHELLDER" + }, + "SHUCKLE": { + "animLabel": "ShuckleAnimation", + "back": "shuckle_back", + "backLabel": "ShuckleBackpic", + "bitmaskLabel": "ShuckleBitmasks", + "dexLabel": "ShucklePokedexEntry", + "framesLabel": "ShuckleFrames", + "front": "shuckle", + "frontLabel": "ShuckleFrontpic", + "id": "SHUCKLE", + "idleLabel": "ShuckleAnimationIdle", + "name": "SHUCKLE" + }, + "SKARMORY": { + "animLabel": "SkarmoryAnimation", + "back": "skarmory_back", + "backLabel": "SkarmoryBackpic", + "bitmaskLabel": "SkarmoryBitmasks", + "dexLabel": "SkarmoryPokedexEntry", + "framesLabel": "SkarmoryFrames", + "front": "skarmory", + "frontLabel": "SkarmoryFrontpic", + "id": "SKARMORY", + "idleLabel": "SkarmoryAnimationIdle", + "name": "SKARMORY" + }, + "SKIPLOOM": { + "animLabel": "SkiploomAnimation", + "back": "skiploom_back", + "backLabel": "SkiploomBackpic", + "bitmaskLabel": "SkiploomBitmasks", + "dexLabel": "SkiploomPokedexEntry", + "framesLabel": "SkiploomFrames", + "front": "skiploom", + "frontLabel": "SkiploomFrontpic", + "id": "SKIPLOOM", + "idleLabel": "SkiploomAnimationIdle", + "name": "SKIPLOOM" + }, + "SLOWBRO": { + "animLabel": "SlowbroAnimation", + "back": "slowbro_back", + "backLabel": "SlowbroBackpic", + "bitmaskLabel": "SlowbroBitmasks", + "dexLabel": "SlowbroPokedexEntry", + "framesLabel": "SlowbroFrames", + "front": "slowbro", + "frontLabel": "SlowbroFrontpic", + "id": "SLOWBRO", + "idleLabel": "SlowbroAnimationIdle", + "name": "SLOWBRO" + }, + "SLOWKING": { + "animLabel": "SlowkingAnimation", + "back": "slowking_back", + "backLabel": "SlowkingBackpic", + "bitmaskLabel": "SlowkingBitmasks", + "dexLabel": "SlowkingPokedexEntry", + "framesLabel": "SlowkingFrames", + "front": "slowking", + "frontLabel": "SlowkingFrontpic", + "id": "SLOWKING", + "idleLabel": "SlowkingAnimationIdle", + "name": "SLOWKING" + }, + "SLOWPOKE": { + "animLabel": "SlowpokeAnimation", + "back": "slowpoke_back", + "backLabel": "SlowpokeBackpic", + "bitmaskLabel": "SlowpokeBitmasks", + "dexLabel": "SlowpokePokedexEntry", + "framesLabel": "SlowpokeFrames", + "front": "slowpoke", + "frontLabel": "SlowpokeFrontpic", + "id": "SLOWPOKE", + "idleLabel": "SlowpokeAnimationIdle", + "name": "SLOWPOKE" + }, + "SLUGMA": { + "animLabel": "SlugmaAnimation", + "back": "slugma_back", + "backLabel": "SlugmaBackpic", + "bitmaskLabel": "SlugmaBitmasks", + "dexLabel": "SlugmaPokedexEntry", + "framesLabel": "SlugmaFrames", + "front": "slugma", + "frontLabel": "SlugmaFrontpic", + "id": "SLUGMA", + "idleLabel": "SlugmaAnimationIdle", + "name": "SLUGMA" + }, + "SMEARGLE": { + "animLabel": "SmeargleAnimation", + "back": "smeargle_back", + "backLabel": "SmeargleBackpic", + "bitmaskLabel": "SmeargleBitmasks", + "dexLabel": "SmearglePokedexEntry", + "framesLabel": "SmeargleFrames", + "front": "smeargle", + "frontLabel": "SmeargleFrontpic", + "id": "SMEARGLE", + "idleLabel": "SmeargleAnimationIdle", + "name": "SMEARGLE" + }, + "SMOOCHUM": { + "animLabel": "SmoochumAnimation", + "back": "smoochum_back", + "backLabel": "SmoochumBackpic", + "bitmaskLabel": "SmoochumBitmasks", + "dexLabel": "SmoochumPokedexEntry", + "framesLabel": "SmoochumFrames", + "front": "smoochum", + "frontLabel": "SmoochumFrontpic", + "id": "SMOOCHUM", + "idleLabel": "SmoochumAnimationIdle", + "name": "SMOOCHUM" + }, + "SNEASEL": { + "animLabel": "SneaselAnimation", + "back": "sneasel_back", + "backLabel": "SneaselBackpic", + "bitmaskLabel": "SneaselBitmasks", + "dexLabel": "SneaselPokedexEntry", + "framesLabel": "SneaselFrames", + "front": "sneasel", + "frontLabel": "SneaselFrontpic", + "id": "SNEASEL", + "idleLabel": "SneaselAnimationIdle", + "name": "SNEASEL" + }, + "SNORLAX": { + "animLabel": "SnorlaxAnimation", + "back": "snorlax_back", + "backLabel": "SnorlaxBackpic", + "bitmaskLabel": "SnorlaxBitmasks", + "dexLabel": "SnorlaxPokedexEntry", + "framesLabel": "SnorlaxFrames", + "front": "snorlax", + "frontLabel": "SnorlaxFrontpic", + "id": "SNORLAX", + "idleLabel": "SnorlaxAnimationIdle", + "name": "SNORLAX" + }, + "SNUBBULL": { + "animLabel": "SnubbullAnimation", + "back": "snubbull_back", + "backLabel": "SnubbullBackpic", + "bitmaskLabel": "SnubbullBitmasks", + "dexLabel": "SnubbullPokedexEntry", + "framesLabel": "SnubbullFrames", + "front": "snubbull", + "frontLabel": "SnubbullFrontpic", + "id": "SNUBBULL", + "idleLabel": "SnubbullAnimationIdle", + "name": "SNUBBULL" + }, + "SPEAROW": { + "animLabel": "SpearowAnimation", + "back": "spearow_back", + "backLabel": "SpearowBackpic", + "bitmaskLabel": "SpearowBitmasks", + "dexLabel": "SpearowPokedexEntry", + "framesLabel": "SpearowFrames", + "front": "spearow", + "frontLabel": "SpearowFrontpic", + "id": "SPEAROW", + "idleLabel": "SpearowAnimationIdle", + "name": "SPEAROW" + }, + "SPINARAK": { + "animLabel": "SpinarakAnimation", + "back": "spinarak_back", + "backLabel": "SpinarakBackpic", + "bitmaskLabel": "SpinarakBitmasks", + "dexLabel": "SpinarakPokedexEntry", + "framesLabel": "SpinarakFrames", + "front": "spinarak", + "frontLabel": "SpinarakFrontpic", + "id": "SPINARAK", + "idleLabel": "SpinarakAnimationIdle", + "name": "SPINARAK" + }, + "SQUIRTLE": { + "animLabel": "SquirtleAnimation", + "back": "squirtle_back", + "backLabel": "SquirtleBackpic", + "bitmaskLabel": "SquirtleBitmasks", + "dexLabel": "SquirtlePokedexEntry", + "framesLabel": "SquirtleFrames", + "front": "squirtle", + "frontLabel": "SquirtleFrontpic", + "id": "SQUIRTLE", + "idleLabel": "SquirtleAnimationIdle", + "name": "SQUIRTLE" + }, + "STANTLER": { + "animLabel": "StantlerAnimation", + "back": "stantler_back", + "backLabel": "StantlerBackpic", + "bitmaskLabel": "StantlerBitmasks", + "dexLabel": "StantlerPokedexEntry", + "framesLabel": "StantlerFrames", + "front": "stantler", + "frontLabel": "StantlerFrontpic", + "id": "STANTLER", + "idleLabel": "StantlerAnimationIdle", + "name": "STANTLER" + }, + "STARMIE": { + "animLabel": "StarmieAnimation", + "back": "starmie_back", + "backLabel": "StarmieBackpic", + "bitmaskLabel": "StarmieBitmasks", + "dexLabel": "StarmiePokedexEntry", + "framesLabel": "StarmieFrames", + "front": "starmie", + "frontLabel": "StarmieFrontpic", + "id": "STARMIE", + "idleLabel": "StarmieAnimationIdle", + "name": "STARMIE" + }, + "STARYU": { + "animLabel": "StaryuAnimation", + "back": "staryu_back", + "backLabel": "StaryuBackpic", + "bitmaskLabel": "StaryuBitmasks", + "dexLabel": "StaryuPokedexEntry", + "framesLabel": "StaryuFrames", + "front": "staryu", + "frontLabel": "StaryuFrontpic", + "id": "STARYU", + "idleLabel": "StaryuAnimationIdle", + "name": "STARYU" + }, + "STEELIX": { + "animLabel": "SteelixAnimation", + "back": "steelix_back", + "backLabel": "SteelixBackpic", + "bitmaskLabel": "SteelixBitmasks", + "dexLabel": "SteelixPokedexEntry", + "framesLabel": "SteelixFrames", + "front": "steelix", + "frontLabel": "SteelixFrontpic", + "id": "STEELIX", + "idleLabel": "SteelixAnimationIdle", + "name": "STEELIX" + }, + "SUDOWOODO": { + "animLabel": "SudowoodoAnimation", + "back": "sudowoodo_back", + "backLabel": "SudowoodoBackpic", + "bitmaskLabel": "SudowoodoBitmasks", + "dexLabel": "SudowoodoPokedexEntry", + "framesLabel": "SudowoodoFrames", + "front": "sudowoodo", + "frontLabel": "SudowoodoFrontpic", + "id": "SUDOWOODO", + "idleLabel": "SudowoodoAnimationIdle", + "name": "SUDOWOODO" + }, + "SUICUNE": { + "animLabel": "SuicuneAnimation", + "back": "suicune_back", + "backLabel": "SuicuneBackpic", + "bitmaskLabel": "SuicuneBitmasks", + "dexLabel": "SuicunePokedexEntry", + "framesLabel": "SuicuneFrames", + "front": "suicune", + "frontLabel": "SuicuneFrontpic", + "id": "SUICUNE", + "idleLabel": "SuicuneAnimationIdle", + "name": "SUICUNE" + }, + "SUNFLORA": { + "animLabel": "SunfloraAnimation", + "back": "sunflora_back", + "backLabel": "SunfloraBackpic", + "bitmaskLabel": "SunfloraBitmasks", + "dexLabel": "SunfloraPokedexEntry", + "framesLabel": "SunfloraFrames", + "front": "sunflora", + "frontLabel": "SunfloraFrontpic", + "id": "SUNFLORA", + "idleLabel": "SunfloraAnimationIdle", + "name": "SUNFLORA" + }, + "SUNKERN": { + "animLabel": "SunkernAnimation", + "back": "sunkern_back", + "backLabel": "SunkernBackpic", + "bitmaskLabel": "SunkernBitmasks", + "dexLabel": "SunkernPokedexEntry", + "framesLabel": "SunkernFrames", + "front": "sunkern", + "frontLabel": "SunkernFrontpic", + "id": "SUNKERN", + "idleLabel": "SunkernAnimationIdle", + "name": "SUNKERN" + }, + "SWINUB": { + "animLabel": "SwinubAnimation", + "back": "swinub_back", + "backLabel": "SwinubBackpic", + "bitmaskLabel": "SwinubBitmasks", + "dexLabel": "SwinubPokedexEntry", + "framesLabel": "SwinubFrames", + "front": "swinub", + "frontLabel": "SwinubFrontpic", + "id": "SWINUB", + "idleLabel": "SwinubAnimationIdle", + "name": "SWINUB" + }, + "TANGELA": { + "animLabel": "TangelaAnimation", + "back": "tangela_back", + "backLabel": "TangelaBackpic", + "bitmaskLabel": "TangelaBitmasks", + "dexLabel": "TangelaPokedexEntry", + "framesLabel": "TangelaFrames", + "front": "tangela", + "frontLabel": "TangelaFrontpic", + "id": "TANGELA", + "idleLabel": "TangelaAnimationIdle", + "name": "TANGELA" + }, + "TAUROS": { + "animLabel": "TaurosAnimation", + "back": "tauros_back", + "backLabel": "TaurosBackpic", + "bitmaskLabel": "TaurosBitmasks", + "dexLabel": "TaurosPokedexEntry", + "framesLabel": "TaurosFrames", + "front": "tauros", + "frontLabel": "TaurosFrontpic", + "id": "TAUROS", + "idleLabel": "TaurosAnimationIdle", + "name": "TAUROS" + }, + "TEDDIURSA": { + "animLabel": "TeddiursaAnimation", + "back": "teddiursa_back", + "backLabel": "TeddiursaBackpic", + "bitmaskLabel": "TeddiursaBitmasks", + "dexLabel": "TeddiursaPokedexEntry", + "framesLabel": "TeddiursaFrames", + "front": "teddiursa", + "frontLabel": "TeddiursaFrontpic", + "id": "TEDDIURSA", + "idleLabel": "TeddiursaAnimationIdle", + "name": "TEDDIURSA" + }, + "TENTACOOL": { + "animLabel": "TentacoolAnimation", + "back": "tentacool_back", + "backLabel": "TentacoolBackpic", + "bitmaskLabel": "TentacoolBitmasks", + "dexLabel": "TentacoolPokedexEntry", + "framesLabel": "TentacoolFrames", + "front": "tentacool", + "frontLabel": "TentacoolFrontpic", + "id": "TENTACOOL", + "idleLabel": "TentacoolAnimationIdle", + "name": "TENTACOOL" + }, + "TENTACRUEL": { + "animLabel": "TentacruelAnimation", + "back": "tentacruel_back", + "backLabel": "TentacruelBackpic", + "bitmaskLabel": "TentacruelBitmasks", + "dexLabel": "TentacruelPokedexEntry", + "framesLabel": "TentacruelFrames", + "front": "tentacruel", + "frontLabel": "TentacruelFrontpic", + "id": "TENTACRUEL", + "idleLabel": "TentacruelAnimationIdle", + "name": "TENTACRUEL" + }, + "TOGEPI": { + "animLabel": "TogepiAnimation", + "back": "togepi_back", + "backLabel": "TogepiBackpic", + "bitmaskLabel": "TogepiBitmasks", + "dexLabel": "TogepiPokedexEntry", + "framesLabel": "TogepiFrames", + "front": "togepi", + "frontLabel": "TogepiFrontpic", + "id": "TOGEPI", + "idleLabel": "TogepiAnimationIdle", + "name": "TOGEPI" + }, + "TOGETIC": { + "animLabel": "TogeticAnimation", + "back": "togetic_back", + "backLabel": "TogeticBackpic", + "bitmaskLabel": "TogeticBitmasks", + "dexLabel": "TogeticPokedexEntry", + "framesLabel": "TogeticFrames", + "front": "togetic", + "frontLabel": "TogeticFrontpic", + "id": "TOGETIC", + "idleLabel": "TogeticAnimationIdle", + "name": "TOGETIC" + }, + "TOTODILE": { + "animLabel": "TotodileAnimation", + "back": "totodile_back", + "backLabel": "TotodileBackpic", + "bitmaskLabel": "TotodileBitmasks", + "dexLabel": "TotodilePokedexEntry", + "framesLabel": "TotodileFrames", + "front": "totodile", + "frontLabel": "TotodileFrontpic", + "id": "TOTODILE", + "idleLabel": "TotodileAnimationIdle", + "name": "TOTODILE" + }, + "TYPHLOSION": { + "animLabel": "TyphlosionAnimation", + "back": "typhlosion_back", + "backLabel": "TyphlosionBackpic", + "bitmaskLabel": "TyphlosionBitmasks", + "dexLabel": "TyphlosionPokedexEntry", + "framesLabel": "TyphlosionFrames", + "front": "typhlosion", + "frontLabel": "TyphlosionFrontpic", + "id": "TYPHLOSION", + "idleLabel": "TyphlosionAnimationIdle", + "name": "TYPHLOSION" + }, + "TYRANITAR": { + "animLabel": "TyranitarAnimation", + "back": "tyranitar_back", + "backLabel": "TyranitarBackpic", + "bitmaskLabel": "TyranitarBitmasks", + "dexLabel": "TyranitarPokedexEntry", + "framesLabel": "TyranitarFrames", + "front": "tyranitar", + "frontLabel": "TyranitarFrontpic", + "id": "TYRANITAR", + "idleLabel": "TyranitarAnimationIdle", + "name": "TYRANITAR" + }, + "TYROGUE": { + "animLabel": "TyrogueAnimation", + "back": "tyrogue_back", + "backLabel": "TyrogueBackpic", + "bitmaskLabel": "TyrogueBitmasks", + "dexLabel": "TyroguePokedexEntry", + "framesLabel": "TyrogueFrames", + "front": "tyrogue", + "frontLabel": "TyrogueFrontpic", + "id": "TYROGUE", + "idleLabel": "TyrogueAnimationIdle", + "name": "TYROGUE" + }, + "UMBREON": { + "animLabel": "UmbreonAnimation", + "back": "umbreon_back", + "backLabel": "UmbreonBackpic", + "bitmaskLabel": "UmbreonBitmasks", + "dexLabel": "UmbreonPokedexEntry", + "framesLabel": "UmbreonFrames", + "front": "umbreon", + "frontLabel": "UmbreonFrontpic", + "id": "UMBREON", + "idleLabel": "UmbreonAnimationIdle", + "name": "UMBREON" + }, + "UNOWN": { + "animLabel": "UnownAnimation", + "back": null, + "backLabel": null, + "bitmaskLabel": "UnownBitmasks", + "dexLabel": "UnownPokedexEntry", + "framesLabel": "UnownFrames", + "front": null, + "frontLabel": null, + "id": "UNOWN", + "idleLabel": "UnownAnimationIdle", + "name": "UNOWN" + }, + "URSARING": { + "animLabel": "UrsaringAnimation", + "back": "ursaring_back", + "backLabel": "UrsaringBackpic", + "bitmaskLabel": "UrsaringBitmasks", + "dexLabel": "UrsaringPokedexEntry", + "framesLabel": "UrsaringFrames", + "front": "ursaring", + "frontLabel": "UrsaringFrontpic", + "id": "URSARING", + "idleLabel": "UrsaringAnimationIdle", + "name": "URSARING" + }, + "VAPOREON": { + "animLabel": "VaporeonAnimation", + "back": "vaporeon_back", + "backLabel": "VaporeonBackpic", + "bitmaskLabel": "VaporeonBitmasks", + "dexLabel": "VaporeonPokedexEntry", + "framesLabel": "VaporeonFrames", + "front": "vaporeon", + "frontLabel": "VaporeonFrontpic", + "id": "VAPOREON", + "idleLabel": "VaporeonAnimationIdle", + "name": "VAPOREON" + }, + "VENOMOTH": { + "animLabel": "VenomothAnimation", + "back": "venomoth_back", + "backLabel": "VenomothBackpic", + "bitmaskLabel": "VenomothBitmasks", + "dexLabel": "VenomothPokedexEntry", + "framesLabel": "VenomothFrames", + "front": "venomoth", + "frontLabel": "VenomothFrontpic", + "id": "VENOMOTH", + "idleLabel": "VenomothAnimationIdle", + "name": "VENOMOTH" + }, + "VENONAT": { + "animLabel": "VenonatAnimation", + "back": "venonat_back", + "backLabel": "VenonatBackpic", + "bitmaskLabel": "VenonatBitmasks", + "dexLabel": "VenonatPokedexEntry", + "framesLabel": "VenonatFrames", + "front": "venonat", + "frontLabel": "VenonatFrontpic", + "id": "VENONAT", + "idleLabel": "VenonatAnimationIdle", + "name": "VENONAT" + }, + "VENUSAUR": { + "animLabel": "VenusaurAnimation", + "back": "venusaur_back", + "backLabel": "VenusaurBackpic", + "bitmaskLabel": "VenusaurBitmasks", + "dexLabel": "VenusaurPokedexEntry", + "framesLabel": "VenusaurFrames", + "front": "venusaur", + "frontLabel": "VenusaurFrontpic", + "id": "VENUSAUR", + "idleLabel": "VenusaurAnimationIdle", + "name": "VENUSAUR" + }, + "VICTREEBEL": { + "animLabel": "VictreebelAnimation", + "back": "victreebel_back", + "backLabel": "VictreebelBackpic", + "bitmaskLabel": "VictreebelBitmasks", + "dexLabel": "VictreebelPokedexEntry", + "framesLabel": "VictreebelFrames", + "front": "victreebel", + "frontLabel": "VictreebelFrontpic", + "id": "VICTREEBEL", + "idleLabel": "VictreebelAnimationIdle", + "name": "VICTREEBEL" + }, + "VILEPLUME": { + "animLabel": "VileplumeAnimation", + "back": "vileplume_back", + "backLabel": "VileplumeBackpic", + "bitmaskLabel": "VileplumeBitmasks", + "dexLabel": "VileplumePokedexEntry", + "framesLabel": "VileplumeFrames", + "front": "vileplume", + "frontLabel": "VileplumeFrontpic", + "id": "VILEPLUME", + "idleLabel": "VileplumeAnimationIdle", + "name": "VILEPLUME" + }, + "VOLTORB": { + "animLabel": "VoltorbAnimation", + "back": "voltorb_back", + "backLabel": "VoltorbBackpic", + "bitmaskLabel": "VoltorbBitmasks", + "dexLabel": "VoltorbPokedexEntry", + "framesLabel": "VoltorbFrames", + "front": "voltorb", + "frontLabel": "VoltorbFrontpic", + "id": "VOLTORB", + "idleLabel": "VoltorbAnimationIdle", + "name": "VOLTORB" + }, + "VULPIX": { + "animLabel": "VulpixAnimation", + "back": "vulpix_back", + "backLabel": "VulpixBackpic", + "bitmaskLabel": "VulpixBitmasks", + "dexLabel": "VulpixPokedexEntry", + "framesLabel": "VulpixFrames", + "front": "vulpix", + "frontLabel": "VulpixFrontpic", + "id": "VULPIX", + "idleLabel": "VulpixAnimationIdle", + "name": "VULPIX" + }, + "WARTORTLE": { + "animLabel": "WartortleAnimation", + "back": "wartortle_back", + "backLabel": "WartortleBackpic", + "bitmaskLabel": "WartortleBitmasks", + "dexLabel": "WartortlePokedexEntry", + "framesLabel": "WartortleFrames", + "front": "wartortle", + "frontLabel": "WartortleFrontpic", + "id": "WARTORTLE", + "idleLabel": "WartortleAnimationIdle", + "name": "WARTORTLE" + }, + "WEEDLE": { + "animLabel": "WeedleAnimation", + "back": "weedle_back", + "backLabel": "WeedleBackpic", + "bitmaskLabel": "WeedleBitmasks", + "dexLabel": "WeedlePokedexEntry", + "framesLabel": "WeedleFrames", + "front": "weedle", + "frontLabel": "WeedleFrontpic", + "id": "WEEDLE", + "idleLabel": "WeedleAnimationIdle", + "name": "WEEDLE" + }, + "WEEPINBELL": { + "animLabel": "WeepinbellAnimation", + "back": "weepinbell_back", + "backLabel": "WeepinbellBackpic", + "bitmaskLabel": "WeepinbellBitmasks", + "dexLabel": "WeepinbellPokedexEntry", + "framesLabel": "WeepinbellFrames", + "front": "weepinbell", + "frontLabel": "WeepinbellFrontpic", + "id": "WEEPINBELL", + "idleLabel": "WeepinbellAnimationIdle", + "name": "WEEPINBELL" + }, + "WEEZING": { + "animLabel": "WeezingAnimation", + "back": "weezing_back", + "backLabel": "WeezingBackpic", + "bitmaskLabel": "WeezingBitmasks", + "dexLabel": "WeezingPokedexEntry", + "framesLabel": "WeezingFrames", + "front": "weezing", + "frontLabel": "WeezingFrontpic", + "id": "WEEZING", + "idleLabel": "WeezingAnimationIdle", + "name": "WEEZING" + }, + "WIGGLYTUFF": { + "animLabel": "WigglytuffAnimation", + "back": "wigglytuff_back", + "backLabel": "WigglytuffBackpic", + "bitmaskLabel": "WigglytuffBitmasks", + "dexLabel": "WigglytuffPokedexEntry", + "framesLabel": "WigglytuffFrames", + "front": "wigglytuff", + "frontLabel": "WigglytuffFrontpic", + "id": "WIGGLYTUFF", + "idleLabel": "WigglytuffAnimationIdle", + "name": "WIGGLYTUFF" + }, + "WOBBUFFET": { + "animLabel": "WobbuffetAnimation", + "back": "wobbuffet_back", + "backLabel": "WobbuffetBackpic", + "bitmaskLabel": "WobbuffetBitmasks", + "dexLabel": "WobbuffetPokedexEntry", + "framesLabel": "WobbuffetFrames", + "front": "wobbuffet", + "frontLabel": "WobbuffetFrontpic", + "id": "WOBBUFFET", + "idleLabel": "WobbuffetAnimationIdle", + "name": "WOBBUFFET" + }, + "WOOPER": { + "animLabel": "WooperAnimation", + "back": "wooper_back", + "backLabel": "WooperBackpic", + "bitmaskLabel": "WooperBitmasks", + "dexLabel": "WooperPokedexEntry", + "framesLabel": "WooperFrames", + "front": "wooper", + "frontLabel": "WooperFrontpic", + "id": "WOOPER", + "idleLabel": "WooperAnimationIdle", + "name": "WOOPER" + }, + "XATU": { + "animLabel": "XatuAnimation", + "back": "xatu_back", + "backLabel": "XatuBackpic", + "bitmaskLabel": "XatuBitmasks", + "dexLabel": "XatuPokedexEntry", + "framesLabel": "XatuFrames", + "front": "xatu", + "frontLabel": "XatuFrontpic", + "id": "XATU", + "idleLabel": "XatuAnimationIdle", + "name": "XATU" + }, + "YANMA": { + "animLabel": "YanmaAnimation", + "back": "yanma_back", + "backLabel": "YanmaBackpic", + "bitmaskLabel": "YanmaBitmasks", + "dexLabel": "YanmaPokedexEntry", + "framesLabel": "YanmaFrames", + "front": "yanma", + "frontLabel": "YanmaFrontpic", + "id": "YANMA", + "idleLabel": "YanmaAnimationIdle", + "name": "YANMA" + }, + "ZAPDOS": { + "animLabel": "ZapdosAnimation", + "back": "zapdos_back", + "backLabel": "ZapdosBackpic", + "bitmaskLabel": "ZapdosBitmasks", + "dexLabel": "ZapdosPokedexEntry", + "framesLabel": "ZapdosFrames", + "front": "zapdos", + "frontLabel": "ZapdosFrontpic", + "id": "ZAPDOS", + "idleLabel": "ZapdosAnimationIdle", + "name": "ZAPDOS" + }, + "ZUBAT": { + "animLabel": "ZubatAnimation", + "back": "zubat_back", + "backLabel": "ZubatBackpic", + "bitmaskLabel": "ZubatBitmasks", + "dexLabel": "ZubatPokedexEntry", + "framesLabel": "ZubatFrames", + "front": "zubat", + "frontLabel": "ZubatFrontpic", + "id": "ZUBAT", + "idleLabel": "ZubatAnimationIdle", + "name": "ZUBAT" + } + }, + "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "symbols": { + "AbraBackpic": [ + 86, + 25647 + ], + "AbraFrontpic": [ + 81, + 30783 + ], + "AbraPokedexEntry": [ + 96, + 28885 + ], + "AerodactylBackpic": [ + 86, + 25982 + ], + "AerodactylFrontpic": [ + 75, + 24187 + ], + "AerodactylPokedexEntry": [ + 115, + 17782 + ], + "AerodactylPuzzleLZ": [ + 56, + 23035 + ], + "AipomBackpic": [ + 86, + 27985 + ], + "AipomFrontpic": [ + 81, + 30322 + ], + "AipomPokedexEntry": [ + 115, + 23057 + ], + "AlakazamBackpic": [ + 81, + 29861 + ], + "AlakazamFrontpic": [ + 74, + 17581 + ], + "AlakazamPokedexEntry": [ + 110, + 16384 + ], + "AlphabeticalPokedexOrder": [ + 16, + 19557 + ], + "AlreadyAsleepText": [ + 32, + 20929 + ], + "AlreadyConfusedText": [ + 32, + 19916 + ], + "AlreadyParalyzedText": [ + 32, + 21906 + ], + "AlreadyPoisonedText": [ + 32, + 20986 + ], + "AmpharosBackpic": [ + 88, + 26249 + ], + "AmpharosFrontpic": [ + 77, + 22993 + ], + "AmpharosPokedexEntry": [ + 115, + 22054 + ], + "AnimObjGFX": [ + 51, + 31990 + ], + "AnimateFlowerTile": [ + 63, + 17773 + ], + "AnimateFlowerTile.FlowerTileFrames": [ + 63, + 17804 + ], + "AnimateFountainTile": [ + 63, + 17287 + ], + "AnimateFrontpic": [ + 52, + 16526 + ], + "AnimateHallOfFame.String_NewHallOfFamer": [ + 33, + 25900 + ], + "AnimateLavaBubbleTile1": [ + 63, + 17868 + ], + "AnimateLavaBubbleTile2": [ + 63, + 17899 + ], + "AnimateTowerPillarTile": [ + 63, + 17989 + ], + "AnimateTrademonFrontpic": [ + 19, + 22558 + ], + "AnimateWaterPalette": [ + 63, + 18135 + ], + "AnimateWaterTile": [ + 63, + 17410 + ], + "AnimateWaterTile.WaterTileFrames": [ + 63, + 17436 + ], + "AnimateWhirlpoolTile": [ + 63, + 18040 + ], + "AnimationIdlePointers": [ + 52, + 22179 + ], + "AnimationPointers": [ + 52, + 18069 + ], + "ArbokBackpic": [ + 88, + 22126 + ], + "ArbokFrontpic": [ + 73, + 28016 + ], + "ArbokPokedexEntry": [ + 96, + 24637 + ], + "ArcanineBackpic": [ + 83, + 30930 + ], + "ArcanineFrontpic": [ + 73, + 23940 + ], + "ArcaninePokedexEntry": [ + 96, + 28444 + ], + "AriadosBackpic": [ + 86, + 19864 + ], + "AriadosFrontpic": [ + 77, + 16384 + ], + "AriadosPokedexEntry": [ + 115, + 20611 + ], + "ArticunoBackpic": [ + 88, + 28867 + ], + "ArticunoFrontpic": [ + 72, + 26326 + ], + "ArticunoPokedexEntry": [ + 115, + 17970 + ], + "AsciiFontGFX": [ + 92, + 23985 + ], + "AttackMissed2Text": [ + 32, + 20531 + ], + "AttackMissedText": [ + 32, + 20512 + ], + "AzumarillBackpic": [ + 88, + 26780 + ], + "AzumarillFrontpic": [ + 76, + 29842 + ], + "AzumarillPokedexEntry": [ + 115, + 22385 + ], + "BTTrainerClassSprites": [ + 92, + 19344 + ], + "BadgeGFX": [ + 9, + 24643 + ], + "BadlyPoisonedText": [ + 32, + 20966 + ], + "BargainShopData": [ + 5, + 23633 + ], + "BaseData": [ + 20, + 21540 + ], + "BattleAnimFrameData": [ + 51, + 26718 + ], + "BattleAnimOAMData": [ + 51, + 28334 + ], + "BattleAnimObjects": [ + 51, + 19286 + ], + "BattleAnimations": [ + 50, + 20591 + ], + "BattleObjectPals": [ + 2, + 22428 + ], + "BattleText_AnEGGCantBattle": [ + 32, + 19238 + ], + "BattleText_CantEscape": [ + 32, + 19360 + ], + "BattleText_CantEscape2": [ + 32, + 19259 + ], + "BattleText_EnemyFled": [ + 32, + 18383 + ], + "BattleText_EnemyIsAboutToUseWillPlayerChangeMon": [ + 32, + 19146 + ], + "BattleText_EnemyMonFainted": [ + 32, + 18856 + ], + "BattleText_EnemySentOut": [ + 32, + 19192 + ], + "BattleText_EnemyWasDefeated": [ + 32, + 18906 + ], + "BattleText_GotAwaySafely": [ + 32, + 19319 + ], + "BattleText_ItemHealedConfusion": [ + 32, + 19883 + ], + "BattleText_ItemsCantBeUsedHere": [ + 32, + 19443 + ], + "BattleText_LinkErrorBattleCanceled": [ + 32, + 22627 + ], + "BattleText_MonCantBeRecalled": [ + 32, + 19490 + ], + "BattleText_MonFainted": [ + 32, + 19061 + ], + "BattleText_MonHasNoMovesLeft": [ + 32, + 19570 + ], + "BattleText_MonIsAlreadyOut": [ + 32, + 19469 + ], + "BattleText_MonsLightScreenFell": [ + 32, + 18663 + ], + "BattleText_MonsReflectFaded": [ + 32, + 18693 + ], + "BattleText_NoTimeLeftToday": [ + 32, + 22670 + ], + "BattleText_PlayerPickedUpPayDayMoney": [ + 32, + 18224 + ], + "BattleText_RainContinuesToFall": [ + 32, + 18719 + ], + "BattleText_SafeguardFaded": [ + 32, + 18642 + ], + "BattleText_StringBuffer1GrewToLevel": [ + 32, + 19612 + ], + "BattleText_TargetRecoveredWithItem": [ + 32, + 18560 + ], + "BattleText_TargetWasHitByFutureSight": [ + 32, + 18614 + ], + "BattleText_TargetsEncoreEnded": [ + 32, + 19594 + ], + "BattleText_TheMoveIsDisabled": [ + 32, + 19547 + ], + "BattleText_TheRainStopped": [ + 32, + 18791 + ], + "BattleText_TheSandstormRages": [ + 32, + 18769 + ], + "BattleText_TheSandstormSubsided": [ + 32, + 18831 + ], + "BattleText_TheSunlightFaded": [ + 32, + 18810 + ], + "BattleText_TheSunlightIsStrong": [ + 32, + 18744 + ], + "BattleText_TheresNoEscapeFromTrainerBattle": [ + 32, + 19273 + ], + "BattleText_TheresNoPPLeftForThisMove": [ + 32, + 19513 + ], + "BattleText_TheresNoWillToBattle": [ + 32, + 19211 + ], + "BattleText_UseNextMon": [ + 32, + 19075 + ], + "BattleText_UserFledUsingAStringBuffer1": [ + 32, + 19337 + ], + "BattleText_UserHurtBySpikes": [ + 32, + 19374 + ], + "BattleText_UserRecoveredPPUsing": [ + 32, + 18585 + ], + "BattleText_UserWasReleasedFromStringBuffer1": [ + 32, + 19957 + ], + "BattleText_UsersHurtByStringBuffer1": [ + 32, + 19938 + ], + "BattleText_UsersStringBuffer1Activated": [ + 32, + 19422 + ], + "BattleText_WildFled": [ + 32, + 18365 + ], + "BattleText_WildMonIsAngry": [ + 32, + 19665 + ], + "BattleText_WildMonIsEating": [ + 32, + 19642 + ], + "BattleTowerMons": [ + 126, + 17488 + ], + "BattleTowerTrainerData": [ + 124, + 16384 + ], + "BattleTowerTrainers": [ + 126, + 16718 + ], + "BattleWinSlideInEnemyTrainerFrontpic": [ + 15, + 27608 + ], + "BayleefBackpic": [ + 83, + 22165 + ], + "BayleefFrontpic": [ + 77, + 26563 + ], + "BayleefPokedexEntry": [ + 115, + 18921 + ], + "BeatUpAttackText": [ + 32, + 22573 + ], + "BecameConfusedText": [ + 32, + 19863 + ], + "BeedrillBackpic": [ + 83, + 30536 + ], + "BeedrillFrontpic": [ + 73, + 22902 + ], + "BeedrillPokedexEntry": [ + 96, + 23690 + ], + "BeganToNapText": [ + 32, + 20273 + ], + "BellChimedText": [ + 32, + 20896 + ], + "BellossomBackpic": [ + 85, + 31844 + ], + "BellossomFrontpic": [ + 78, + 19124 + ], + "BellossomPokedexEntry": [ + 115, + 22170 + ], + "BellsproutBackpic": [ + 88, + 18720 + ], + "BellsproutFrontpic": [ + 82, + 23595 + ], + "BellsproutPokedexEntry": [ + 110, + 16801 + ], + "BellyDrumText": [ + 32, + 22482 + ], + "BillsPCOrangePalette": [ + 2, + 20534 + ], + "BitmasksPointers": [ + 52, + 25839 + ], + "BlastoiseBackpic": [ + 85, + 20786 + ], + "BlastoiseFrontpic": [ + 73, + 16540 + ], + "BlastoisePokedexEntry": [ + 96, + 23033 + ], + "BlewSpikesText": [ + 32, + 22414 + ], + "BlisseyBackpic": [ + 88, + 25981 + ], + "BlisseyFrontpic": [ + 75, + 19884 + ], + "BlisseyPokedexEntry": [ + 116, + 21751 + ], + "BlownAwayText": [ + 32, + 21202 + ], + "BoltEmote": [ + 65, + 17552 + ], + "BracedItselfText": [ + 32, + 22265 + ], + "BugCatchingContestantEventFlagTable": [ + 4, + 31230 + ], + "BugContestPrizeNoRoomText": [ + 108, + 18636 + ], + "BulbasaurBackpic": [ + 88, + 17563 + ], + "BulbasaurFrontpic": [ + 83, + 32109 + ], + "BulbasaurPokedexEntry": [ + 96, + 22165 + ], + "ButItFailedText": [ + 32, + 21744 + ], + "ButterfreeBackpic": [ + 81, + 28473 + ], + "ButterfreeFrontpic": [ + 74, + 25162 + ], + "ButterfreePokedexEntry": [ + 96, + 23365 + ], + "CalPic": [ + 86, + 17084 + ], + "CantEscapeNowText": [ + 32, + 22007 + ], + "CardFlipLZ01": [ + 56, + 19734 + ], + "CardFlipLZ02": [ + 56, + 20136 + ], + "CardFlipLZ03": [ + 56, + 19675 + ], + "CardFlipOffButtonGFX": [ + 56, + 19702 + ], + "CardFlipOnButtonGFX": [ + 56, + 19718 + ], + "CardFlipTilemap": [ + 56, + 20748 + ], + "CardLargeSpriteAndFolderGFX": [ + 94, + 25023 + ], + "CardSpriteGFX": [ + 94, + 26191 + ], + "CardStatusGFX": [ + 9, + 21795 + ], + "CardTradeGFX": [ + 65, + 22832 + ], + "CardTradeSpriteGFX": [ + 65, + 23856 + ], + "CaterpieBackpic": [ + 88, + 20434 + ], + "CaterpieFrontpic": [ + 87, + 23750 + ], + "CaterpiePokedexEntry": [ + 96, + 23136 + ], + "CelebiBackpic": [ + 82, + 31339 + ], + "CelebiFrontpic": [ + 81, + 17889 + ], + "CelebiPokedexEntry": [ + 116, + 22732 + ], + "ChanseyBackpic": [ + 87, + 28388 + ], + "ChanseyFrontpic": [ + 79, + 17666 + ], + "ChanseyPokedexEntry": [ + 110, + 21550 + ], + "CharizardBackpic": [ + 85, + 23674 + ], + "CharizardFrontpic": [ + 73, + 31031 + ], + "CharizardPokedexEntry": [ + 96, + 22707 + ], + "CharmanderBackpic": [ + 87, + 29899 + ], + "CharmanderFrontpic": [ + 80, + 24677 + ], + "CharmanderPokedexEntry": [ + 96, + 22491 + ], + "CharmeleonBackpic": [ + 84, + 18725 + ], + "CharmeleonFrontpic": [ + 76, + 26753 + ], + "CharmeleonPokedexEntry": [ + 96, + 22602 + ], + "ChikoritaBackpic": [ + 87, + 27468 + ], + "ChikoritaFrontpic": [ + 79, + 22680 + ], + "ChikoritaPokedexEntry": [ + 115, + 18824 + ], + "ChinchouBackpic": [ + 87, + 28082 + ], + "ChinchouFrontpic": [ + 80, + 22018 + ], + "ChinchouPokedexEntry": [ + 115, + 20837 + ], + "ChooseMobileCenterAttrmap": [ + 92, + 26965 + ], + "ChooseMobileCenterTilemap": [ + 92, + 26245 + ], + "ChrisBackpic": [ + 10, + 31258 + ], + "ChrisCardPic": [ + 34, + 17253 + ], + "ChrisPic": [ + 34, + 18601 + ], + "ChrisSilhouetteGFX": [ + 94, + 23535 + ], + "ChrisSpriteGFX": [ + 48, + 16384 + ], + "ClampedByText": [ + 32, + 20057 + ], + "ClefableBackpic": [ + 82, + 27514 + ], + "ClefableFrontpic": [ + 78, + 22484 + ], + "ClefablePokedexEntry": [ + 96, + 25904 + ], + "ClefairyBackpic": [ + 85, + 31142 + ], + "ClefairyFrontpic": [ + 82, + 16839 + ], + "ClefairyPokedexEntry": [ + 96, + 25800 + ], + "CleffaBackpic": [ + 88, + 29888 + ], + "CleffaFrontpic": [ + 82, + 24921 + ], + "CleffaPokedexEntry": [ + 115, + 21180 + ], + "ClockTilemapRLE": [ + 36, + 21979 + ], + "CloysterBackpic": [ + 81, + 31700 + ], + "CloysterFrontpic": [ + 74, + 26980 + ], + "CloysterPokedexEntry": [ + 110, + 19161 + ], + "CoinVendor_Buy500CoinsText": [ + 108, + 18511 + ], + "CoinVendor_Buy50CoinsText": [ + 108, + 18480 + ], + "CoinVendor_CancelText": [ + 108, + 18605 + ], + "CoinVendor_CoinCaseFullText": [ + 108, + 18572 + ], + "CoinVendor_IntroText": [ + 108, + 18403 + ], + "CoinVendor_NoCoinCaseText": [ + 108, + 18332 + ], + "CoinVendor_NotEnoughMoneyText": [ + 108, + 18543 + ], + "CoinVendor_WelcomeText": [ + 108, + 18303 + ], + "CoinsScatteredText": [ + 32, + 21557 + ], + "ConfusedNoMoreText": [ + 32, + 19841 + ], + "ContestMons": [ + 37, + 32135 + ], + "ContestResults_ConsolationPrizeText": [ + 108, + 18049 + ], + "ContestResults_DidNotWinText": [ + 108, + 18103 + ], + "ContestResults_JoinUsNextTimeText": [ + 108, + 18011 + ], + "ContestResults_PartyFullText": [ + 108, + 18189 + ], + "ContestResults_PlayerWonAPrizeText": [ + 108, + 17953 + ], + "ContestResults_ReadyToJudgeText": [ + 108, + 17855 + ], + "ContestResults_ReturnPartyText": [ + 108, + 18137 + ], + "CopiedStatsText": [ + 32, + 22518 + ], + "CopyBackpic": [ + 15, + 31792 + ], + "CopyrightGFX": [ + 57, + 16384 + ], + "CorsolaBackpic": [ + 87, + 27775 + ], + "CorsolaFrontpic": [ + 80, + 20370 + ], + "CorsolaPokedexEntry": [ + 116, + 19560 + ], + "CoveredByVeilText": [ + 32, + 22300 + ], + "CrashedText": [ + 32, + 20550 + ], + "CreditsBorderGFX": [ + 66, + 23588 + ], + "CreditsDittoGFX": [ + 66, + 25780 + ], + "CreditsIgglybuffGFX": [ + 66, + 26804 + ], + "CreditsPalettes": [ + 66, + 23402 + ], + "CreditsPichuGFX": [ + 66, + 23732 + ], + "CreditsSmoochumGFX": [ + 66, + 24756 + ], + "Cries": [ + 58, + 20912 + ], + "CriticalHitText": [ + 32, + 20614 + ], + "CrobatBackpic": [ + 88, + 25712 + ], + "CrobatFrontpic": [ + 79, + 17028 + ], + "CrobatPokedexEntry": [ + 115, + 20725 + ], + "CroconawBackpic": [ + 83, + 25793 + ], + "CroconawFrontpic": [ + 75, + 26730 + ], + "CroconawPokedexEntry": [ + 115, + 19588 + ], + "CuboneBackpic": [ + 83, + 28566 + ], + "CuboneFrontpic": [ + 80, + 25205 + ], + "CubonePokedexEntry": [ + 110, + 20584 + ], + "CyndaquilBackpic": [ + 83, + 21755 + ], + "CyndaquilFrontpic": [ + 79, + 31025 + ], + "CyndaquilPokedexEntry": [ + 115, + 19133 + ], + "DecorationDesc_GiantOrnament.BigDollScript": [ + 9, + 28643 + ], + "DecorationDesc_NullPoster": [ + 9, + 28600 + ], + "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": [ + 9, + 28629 + ], + "DecorationDesc_PosterPointers": [ + 9, + 28548 + ], + "DefrostedOpponentText": [ + 32, + 21067 + ], + "DelibirdBackpic": [ + 86, + 18826 + ], + "DelibirdFrontpic": [ + 79, + 27496 + ], + "DelibirdPokedexEntry": [ + 116, + 19889 + ], + "DestinyBondEffectText": [ + 32, + 20822 + ], + "DewgongBackpic": [ + 89, + 18066 + ], + "DewgongFrontpic": [ + 74, + 28781 + ], + "DewgongPokedexEntry": [ + 110, + 18740 + ], + "DialpadAttrmap": [ + 94, + 28221 + ], + "DialpadCursorGFX": [ + 94, + 29797 + ], + "DialpadGFX": [ + 94, + 28581 + ], + "DialpadTilemap": [ + 94, + 27861 + ], + "DidntAffect1Text": [ + 32, + 21772 + ], + "DidntAffect2Text": [ + 32, + 21792 + ], + "DifficultBookshelfText": [ + 108, + 17242 + ], + "DiglettBackpic": [ + 89, + 22698 + ], + "DiglettFrontpic": [ + 86, + 17782 + ], + "DiglettPokedexEntry": [ + 96, + 27440 + ], + "DiplomaGFX": [ + 119, + 22533 + ], + "DiplomaPage1Tilemap": [ + 119, + 23627 + ], + "DiplomaPalettes": [ + 2, + 30273 + ], + "DisabledMoveText": [ + 32, + 20226 + ], + "DisabledNoMoreText": [ + 32, + 19769 + ], + "DittoBackpic": [ + 88, + 29123 + ], + "DittoFrontpic": [ + 85, + 29734 + ], + "DittoPokedexEntry": [ + 115, + 16700 + ], + "DodrioBackpic": [ + 82, + 16384 + ], + "DodrioFrontpic": [ + 75, + 16384 + ], + "DodrioPokedexEntry": [ + 110, + 18506 + ], + "DoduoBackpic": [ + 83, + 23380 + ], + "DoduoFrontpic": [ + 82, + 27942 + ], + "DoduoPokedexEntry": [ + 110, + 18391 + ], + "DoesntAffectText": [ + 32, + 20593 + ], + "DoneTileAnimation": [ + 63, + 17147 + ], + "DonphanBackpic": [ + 89, + 19006 + ], + "DonphanFrontpic": [ + 75, + 30892 + ], + "DonphanPokedexEntry": [ + 116, + 20652 + ], + "DownpourText": [ + 32, + 22435 + ], + "DraggedOutText": [ + 32, + 21828 + ], + "DragonairBackpic": [ + 87, + 26854 + ], + "DragonairFrontpic": [ + 78, + 30389 + ], + "DragonairPokedexEntry": [ + 115, + 18396 + ], + "DragoniteBackpic": [ + 88, + 22684 + ], + "DragoniteFrontpic": [ + 75, + 21614 + ], + "DragonitePokedexEntry": [ + 115, + 18499 + ], + "DratiniBackpic": [ + 88, + 31139 + ], + "DratiniFrontpic": [ + 82, + 28797 + ], + "DratiniPokedexEntry": [ + 115, + 18273 + ], + "DrawPocketName.tilemap": [ + 4, + 18913 + ], + "DreamEatenText": [ + 32, + 21031 + ], + "DrowzeeBackpic": [ + 88, + 30140 + ], + "DrowzeeFrontpic": [ + 78, + 17762 + ], + "DrowzeePokedexEntry": [ + 110, + 19722 + ], + "Drumkits": [ + 58, + 20050 + ], + "DudeBackpic": [ + 10, + 31658 + ], + "DugtrioBackpic": [ + 86, + 26650 + ], + "DugtrioFrontpic": [ + 78, + 29090 + ], + "DugtrioPokedexEntry": [ + 96, + 27549 + ], + "DunsparceBackpic": [ + 89, + 18772 + ], + "DunsparceFrontpic": [ + 77, + 32161 + ], + "DunsparcePokedexEntry": [ + 116, + 17811 + ], + "EZChatCursorGFX": [ + 34, + 21515 + ], + "EeveeBackpic": [ + 84, + 24109 + ], + "EeveeFrontpic": [ + 81, + 28008 + ], + "EeveePokedexEntry": [ + 115, + 16818 + ], + "EggHatchGFX": [ + 5, + 29587 + ], + "EggMovePointers": [ + 8, + 31505 + ], + "EggPic": [ + 84, + 23727 + ], + "EkansBackpic": [ + 85, + 26897 + ], + "EkansFrontpic": [ + 80, + 16384 + ], + "EkansPokedexEntry": [ + 96, + 24532 + ], + "ElectabuzzBackpic": [ + 84, + 19886 + ], + "ElectabuzzFrontpic": [ + 76, + 23602 + ], + "ElectabuzzPokedexEntry": [ + 110, + 22826 + ], + "ElectroBallMobileGFX": [ + 92, + 21924 + ], + "ElectrodeBackpic": [ + 88, + 31388 + ], + "ElectrodeFrontpic": [ + 84, + 32370 + ], + "ElectrodePokedexEntry": [ + 110, + 20249 + ], + "ElekidBackpic": [ + 82, + 30918 + ], + "ElekidFrontpic": [ + 77, + 28671 + ], + "ElekidPokedexEntry": [ + 116, + 21400 + ], + "ElevatorFloorNames": [ + 4, + 30083 + ], + "EliminatedStatsText": [ + 32, + 21622 + ], + "EnduredText": [ + 32, + 20144 + ], + "EnemyHPBarBorderGFX": [ + 62, + 19136 + ], + "EnemyHitTimesText": [ + 32, + 21240 + ], + "EnteiBackpic": [ + 86, + 22947 + ], + "EnteiFrontpic": [ + 72, + 29643 + ], + "EnteiPokedexEntry": [ + 116, + 21981 + ], + "EnvironmentColorsPointers": [ + 2, + 29305 + ], + "EspeonBackpic": [ + 86, + 28318 + ], + "EspeonFrontpic": [ + 79, + 23902 + ], + "EspeonPokedexEntry": [ + 116, + 16725 + ], + "EvadedText": [ + 32, + 21511 + ], + "EvosAttacksPointers": [ + 16, + 26033 + ], + "ExeggcuteBackpic": [ + 85, + 25468 + ], + "ExeggcuteFrontpic": [ + 76, + 18010 + ], + "ExeggcutePokedexEntry": [ + 110, + 20356 + ], + "ExeggutorBackpic": [ + 81, + 25194 + ], + "ExeggutorFrontpic": [ + 76, + 28304 + ], + "ExeggutorPokedexEntry": [ + 110, + 20470 + ], + "ExpBarGFX": [ + 62, + 19216 + ], + "ExpBarPalette": [ + 2, + 26826 + ], + "FarfetchDBackpic": [ + 83, + 20522 + ], + "FarfetchDFrontpic": [ + 77, + 25143 + ], + "FarfetchDPokedexEntry": [ + 110, + 18284 + ], + "FastAsleepText": [ + 32, + 19687 + ], + "FearowBackpic": [ + 88, + 30641 + ], + "FearowFrontpic": [ + 73, + 30033 + ], + "FearowPokedexEntry": [ + 96, + 24420 + ], + "FellAsleepText": [ + 32, + 20913 + ], + "FellInLoveText": [ + 32, + 22283 + ], + "FemalePokegearPals": [ + 2, + 30553 + ], + "FeraligatrBackpic": [ + 82, + 28370 + ], + "FeraligatrFrontpic": [ + 72, + 21725 + ], + "FeraligatrPokedexEntry": [ + 115, + 19705 + ], + "FireSpinTrapText": [ + 32, + 20020 + ], + "FishEmote": [ + 65, + 17680 + ], + "FishGroups": [ + 36, + 25736 + ], + "FlaaffyBackpic": [ + 85, + 16753 + ], + "FlaaffyFrontpic": [ + 79, + 29271 + ], + "FlaaffyPokedexEntry": [ + 115, + 21944 + ], + "FlareonBackpic": [ + 83, + 31323 + ], + "FlareonFrontpic": [ + 77, + 24429 + ], + "FlareonPokedexEntry": [ + 115, + 17132 + ], + "FledFromBattleText": [ + 32, + 21164 + ], + "FledInFearText": [ + 32, + 21185 + ], + "FlickeringCaveEntrancePalette": [ + 63, + 18206 + ], + "FlinchedText": [ + 32, + 19738 + ], + "Font": [ + 62, + 16896 + ], + "FontBattleExtra": [ + 62, + 17920 + ], + "FontExtra": [ + 62, + 16384 + ], + "FontsExtra2_UpArrowGFX": [ + 62, + 21540 + ], + "FontsExtra_SolidBlackGFX": [ + 62, + 21012 + ], + "Footprints": [ + 62, + 21556 + ], + "ForesawAttackText": [ + 32, + 22551 + ], + "ForestTreeLeftAnimation": [ + 63, + 17500 + ], + "ForestTreeLeftAnimation2": [ + 63, + 17650 + ], + "ForestTreeRightAnimation": [ + 63, + 17604 + ], + "ForestTreeRightAnimation2": [ + 63, + 17692 + ], + "ForretressBackpic": [ + 89, + 24454 + ], + "ForretressFrontpic": [ + 78, + 31035 + ], + "ForretressPokedexEntry": [ + 116, + 17700 + ], + "Frames": [ + 62, + 18432 + ], + "FramesPointers": [ + 53, + 16384 + ], + "FrozenSolidText": [ + 32, + 19718 + ], + "FullyParalyzedText": [ + 32, + 21885 + ], + "FurretBackpic": [ + 85, + 24752 + ], + "FurretFrontpic": [ + 79, + 30444 + ], + "FurretPokedexEntry": [ + 115, + 19933 + ], + "GSIntro_GetMonFrontpic": [ + 20, + 20972 + ], + "GameFreakDittoGFX": [ + 66, + 21511 + ], + "GameFreakDittoPaletteFade": [ + 57, + 18348 + ], + "GameFreakLogoGFX": [ + 57, + 18380 + ], + "GastlyBackpic": [ + 88, + 30391 + ], + "GastlyFrontpic": [ + 76, + 29076 + ], + "GastlyPokedexEntry": [ + 110, + 19274 + ], + "GengarBackpic": [ + 86, + 29978 + ], + "GengarFrontpic": [ + 78, + 27124 + ], + "GengarPokedexEntry": [ + 110, + 19493 + ], + "GeodudeBackpic": [ + 85, + 18595 + ], + "GeodudeFrontpic": [ + 85, + 21872 + ], + "GeodudePokedexEntry": [ + 110, + 17313 + ], + "GetAnimatedEnemyFrontpic": [ + 20, + 20739 + ], + "GetAnimatedFrontpic": [ + 20, + 20619 + ], + "GetBattleMonBackpic": [ + 15, + 29757 + ], + "GetChrisBackpic": [ + 34, + 18480 + ], + "GetEggFrontpic": [ + 5, + 29220 + ], + "GetEnemyMonFrontpic": [ + 15, + 29820 + ], + "GetHatchlingFrontpic": [ + 5, + 29244 + ], + "GetKrisBackpic": [ + 34, + 20169 + ], + "GetMonBackpic": [ + 20, + 20844 + ], + "GetMonFrontpic": [ + 20, + 20599 + ], + "GetPlayerBackpic": [ + 34, + 18469 + ], + "GetTrademonFrontpic": [ + 19, + 22525 + ], + "GetTrainerBackpic": [ + 15, + 31743 + ], + "GettingPumpedText": [ + 32, + 21304 + ], + "GirafarigBackpic": [ + 85, + 22594 + ], + "GirafarigFrontpic": [ + 78, + 17075 + ], + "GirafarigPokedexEntry": [ + 116, + 17478 + ], + "GligarBackpic": [ + 84, + 17948 + ], + "GligarFrontpic": [ + 77, + 31469 + ], + "GligarPokedexEntry": [ + 116, + 17924 + ], + "GloomBackpic": [ + 83, + 29354 + ], + "GloomFrontpic": [ + 79, + 28683 + ], + "GloomPokedexEntry": [ + 96, + 26790 + ], + "GolbatBackpic": [ + 89, + 23622 + ], + "GolbatFrontpic": [ + 76, + 16384 + ], + "GolbatPokedexEntry": [ + 96, + 26564 + ], + "GoldeenBackpic": [ + 83, + 31716 + ], + "GoldeenFrontpic": [ + 79, + 23292 + ], + "GoldeenPokedexEntry": [ + 110, + 22054 + ], + "GolduckBackpic": [ + 86, + 22266 + ], + "GolduckFrontpic": [ + 74, + 21463 + ], + "GolduckPokedexEntry": [ + 96, + 27989 + ], + "GolemBackpic": [ + 89, + 22308 + ], + "GolemFrontpic": [ + 77, + 19351 + ], + "GolemPokedexEntry": [ + 110, + 17537 + ], + "GotAnEncoreText": [ + 32, + 20745 + ], + "GotMoneyForWinningText": [ + 32, + 18878 + ], + "GranbullBackpic": [ + 87, + 24687 + ], + "GranbullFrontpic": [ + 79, + 19566 + ], + "GranbullPokedexEntry": [ + 116, + 18250 + ], + "GrassRustleGFX": [ + 65, + 17824 + ], + "GravelerBackpic": [ + 86, + 27652 + ], + "GravelerFrontpic": [ + 78, + 24480 + ], + "GravelerPokedexEntry": [ + 110, + 17420 + ], + "GrimerBackpic": [ + 88, + 19007 + ], + "GrimerFrontpic": [ + 80, + 28350 + ], + "GrimerPokedexEntry": [ + 110, + 18850 + ], + "GrowlitheBackpic": [ + 84, + 28641 + ], + "GrowlitheFrontpic": [ + 82, + 17747 + ], + "GrowlithePokedexEntry": [ + 96, + 28330 + ], + "GrowthRates": [ + 20, + 20218 + ], + "GyaradosBackpic": [ + 82, + 25356 + ], + "GyaradosFrontpic": [ + 74, + 18566 + ], + "GyaradosPokedexEntry": [ + 115, + 16485 + ], + "GymStatue_CityGymText": [ + 108, + 18254 + ], + "GymStatue_WinningTrainersText": [ + 108, + 18268 + ], + "HOF_AnimateFrontpic": [ + 52, + 18030 + ], + "HOF_LoadTrainerFrontpic": [ + 34, + 18496 + ], + "HOF_SlideBackpic": [ + 33, + 26165 + ], + "HOF_SlideFrontpic": [ + 33, + 26179 + ], + "HPBarPals": [ + 2, + 26814 + ], + "HPExpBarBorderGFX": [ + 62, + 19168 + ], + "HPIsFullText": [ + 32, + 21812 + ], + "HappinessText1": [ + 108, + 18825 + ], + "HappinessText2": [ + 108, + 18772 + ], + "HappinessText3": [ + 108, + 18730 + ], + "HappyEmote": [ + 65, + 17360 + ], + "HasANightmareText": [ + 32, + 18466 + ], + "HasNoPPLeftText": [ + 32, + 20410 + ], + "HasSubstituteText": [ + 32, + 21368 + ], + "HaunterBackpic": [ + 87, + 20251 + ], + "HaunterFrontpic": [ + 79, + 20819 + ], + "HaunterPokedexEntry": [ + 110, + 19389 + ], + "HaveWantGFX": [ + 95, + 16515 + ], + "HaveWantMap": [ + 95, + 19331 + ], + "HealMachineAnim.HealMachineGFX": [ + 4, + 25596 + ], + "HealMachineAnim.palettes": [ + 4, + 25681 + ], + "HeartEmote": [ + 65, + 17488 + ], + "HeldItemIcons": [ + 35, + 27127 + ], + "HeracrossBackpic": [ + 84, + 21042 + ], + "HeracrossFrontpic": [ + 76, + 32123 + ], + "HeracrossPokedexEntry": [ + 116, + 18695 + ], + "HitmonchanBackpic": [ + 85, + 19694 + ], + "HitmonchanFrontpic": [ + 79, + 31604 + ], + "HitmonchanPokedexEntry": [ + 110, + 20900 + ], + "HitmonleeBackpic": [ + 88, + 22963 + ], + "HitmonleeFrontpic": [ + 73, + 20802 + ], + "HitmonleePokedexEntry": [ + 110, + 20799 + ], + "HitmontopBackpic": [ + 81, + 31242 + ], + "HitmontopFrontpic": [ + 75, + 18143 + ], + "HitmontopPokedexEntry": [ + 116, + 21184 + ], + "HoOhBackpic": [ + 86, + 24304 + ], + "HoOhFrontpic": [ + 72, + 17902 + ], + "HoOhPokedexEntry": [ + 116, + 22621 + ], + "HoOhPuzzleLZ": [ + 56, + 22699 + ], + "HomepageText": [ + 108, + 17609 + ], + "HookedPokemonAttackedText": [ + 32, + 18268 + ], + "HoothootBackpic": [ + 87, + 16384 + ], + "HoothootFrontpic": [ + 81, + 32158 + ], + "HoothootPokedexEntry": [ + 115, + 20056 + ], + "HoppipBackpic": [ + 87, + 24063 + ], + "HoppipFrontpic": [ + 83, + 23784 + ], + "HoppipPokedexEntry": [ + 115, + 22709 + ], + "HorseaBackpic": [ + 88, + 27568 + ], + "HorseaFrontpic": [ + 86, + 23626 + ], + "HorseaPokedexEntry": [ + 110, + 21843 + ], + "HoundoomBackpic": [ + 88, + 18431 + ], + "HoundoomFrontpic": [ + 77, + 21541 + ], + "HoundoomPokedexEntry": [ + 116, + 20326 + ], + "HoundourBackpic": [ + 89, + 17111 + ], + "HoundourFrontpic": [ + 81, + 16887 + ], + "HoundourPokedexEntry": [ + 116, + 20223 + ], + "HungOnText": [ + 32, + 20121 + ], + "HurtByBurnText": [ + 32, + 18424 + ], + "HurtByCurseText": [ + 32, + 18486 + ], + "HurtByPoisonText": [ + 32, + 18402 + ], + "HurtItselfText": [ + 32, + 19807 + ], + "HypnoBackpic": [ + 83, + 24992 + ], + "HypnoFrontpic": [ + 75, + 27574 + ], + "HypnoPokedexEntry": [ + 110, + 19840 + ], + "IconPointers": [ + 35, + 27583 + ], + "Icons": [ + 35, + 27661 + ], + "IdentifiedText": [ + 32, + 22193 + ], + "IgglybuffBackpic": [ + 88, + 26515 + ], + "IgglybuffFrontpic": [ + 85, + 25826 + ], + "IgglybuffPokedexEntry": [ + 115, + 21292 + ], + "IgnoredOrders2Text": [ + 32, + 22608 + ], + "IgnoredOrdersText": [ + 32, + 20326 + ], + "IgnoredSleepingText": [ + 32, + 20347 + ], + "InLoveWithText": [ + 32, + 20164 + ], + "IncenseBurnerText": [ + 108, + 17480 + ], + "InfatuationText": [ + 32, + 20186 + ], + "InitRoamMons": [ + 10, + 25248 + ], + "InitializeEventsScript": [ + 47, + 17371 + ], + "IntroBackgroundAttrmap": [ + 57, + 24269 + ], + "IntroBackgroundGFX": [ + 57, + 23677 + ], + "IntroBackgroundPalette": [ + 57, + 24285 + ], + "IntroBackgroundTilemap": [ + 57, + 24173 + ], + "IntroCrystalUnownsAttrmap": [ + 57, + 26477 + ], + "IntroCrystalUnownsGFX": [ + 57, + 26157 + ], + "IntroCrystalUnownsPalette": [ + 57, + 26525 + ], + "IntroCrystalUnownsTilemap": [ + 57, + 26413 + ], + "IntroGrass1GFX": [ + 57, + 31133 + ], + "IntroGrass2GFX": [ + 57, + 31197 + ], + "IntroGrass3GFX": [ + 57, + 31261 + ], + "IntroGrass4GFX": [ + 57, + 31325 + ], + "IntroPichuWooperGFX": [ + 57, + 22829 + ], + "IntroPulseGFX": [ + 57, + 25421 + ], + "IntroSuicuneBackAttrmap": [ + 57, + 30605 + ], + "IntroSuicuneBackGFX": [ + 57, + 29357 + ], + "IntroSuicuneBackTilemap": [ + 57, + 30397 + ], + "IntroSuicuneCloseAttrmap": [ + 57, + 27917 + ], + "IntroSuicuneCloseGFX": [ + 57, + 26653 + ], + "IntroSuicuneClosePalette": [ + 57, + 28013 + ], + "IntroSuicuneCloseTilemap": [ + 57, + 27709 + ], + "IntroSuicuneJumpAttrmap": [ + 57, + 30381 + ], + "IntroSuicuneJumpGFX": [ + 57, + 28141 + ], + "IntroSuicuneJumpTilemap": [ + 57, + 30285 + ], + "IntroSuicunePalette": [ + 57, + 30685 + ], + "IntroSuicuneRunGFX": [ + 57, + 21853 + ], + "IntroUnownAAttrmap": [ + 57, + 25629 + ], + "IntroUnownATilemap": [ + 57, + 25565 + ], + "IntroUnownBackGFX": [ + 57, + 30813 + ], + "IntroUnownHIAttrmap": [ + 57, + 25725 + ], + "IntroUnownHITilemap": [ + 57, + 25645 + ], + "IntroUnownsAttrmap": [ + 57, + 25949 + ], + "IntroUnownsGFX": [ + 57, + 24413 + ], + "IntroUnownsPalette": [ + 57, + 26029 + ], + "IntroUnownsTilemap": [ + 57, + 25757 + ], + "Intro_FadeUnownWordPals.FastFadePalettes": [ + 57, + 21467 + ], + "Intro_FadeUnownWordPals.SlowFadePalettes": [ + 57, + 21499 + ], + "Intro_LoadMagikarpPalettes.MagikarpBGPal": [ + 2, + 19247 + ], + "Intro_LoadMagikarpPalettes.MagikarpOBPal": [ + 2, + 19255 + ], + "Intro_RotatePalettesLeftFrontpic": [ + 1, + 24938 + ], + "Intro_Scene20_AppearUnown.pal1": [ + 57, + 21389 + ], + "Intro_Scene20_AppearUnown.pal2": [ + 57, + 21397 + ], + "Intro_Scene24_ApplyPaletteFade.FadePals": [ + 57, + 20892 + ], + "Intro_WipeInFrontpic": [ + 1, + 24962 + ], + "IsConfusedText": [ + 32, + 19791 + ], + "ItFailedText": [ + 32, + 21760 + ], + "ItemAttributes": [ + 1, + 26561 + ], + "ItemDescriptions": [ + 114, + 18823 + ], + "ItemNames": [ + 114, + 16384 + ], + "IvysaurBackpic": [ + 86, + 21239 + ], + "IvysaurFrontpic": [ + 79, + 29858 + ], + "IvysaurPokedexEntry": [ + 96, + 22274 + ], + "JigglypuffBackpic": [ + 88, + 32125 + ], + "JigglypuffFrontpic": [ + 81, + 20355 + ], + "JigglypuffPokedexEntry": [ + 96, + 26227 + ], + "JohtoGrassWildMons": [ + 10, + 26089 + ], + "JohtoMap": [ + 36, + 24575 + ], + "JohtoWaterWildMons": [ + 10, + 28957 + ], + "JolteonBackpic": [ + 87, + 23435 + ], + "JolteonFrontpic": [ + 75, + 30069 + ], + "JolteonPokedexEntry": [ + 115, + 17035 + ], + "JumpluffBackpic": [ + 85, + 20059 + ], + "JumpluffFrontpic": [ + 77, + 29373 + ], + "JumpluffPokedexEntry": [ + 115, + 22936 + ], + "JynxBackpic": [ + 89, + 23443 + ], + "JynxFrontpic": [ + 79, + 26304 + ], + "JynxPokedexEntry": [ + 110, + 22725 + ], + "KabutoBackpic": [ + 87, + 17677 + ], + "KabutoFrontpic": [ + 85, + 28675 + ], + "KabutoPokedexEntry": [ + 115, + 17559 + ], + "KabutoPuzzleLZ": [ + 56, + 23467 + ], + "KabutopsBackpic": [ + 83, + 30142 + ], + "KabutopsFrontpic": [ + 77, + 22268 + ], + "KabutopsPokedexEntry": [ + 115, + 17671 + ], + "KadabraBackpic": [ + 85, + 24034 + ], + "KadabraFrontpic": [ + 76, + 27531 + ], + "KadabraPokedexEntry": [ + 96, + 28988 + ], + "KakunaBackpic": [ + 88, + 24897 + ], + "KakunaFrontpic": [ + 86, + 26316 + ], + "KakunaPokedexEntry": [ + 96, + 23587 + ], + "KangaskhanBackpic": [ + 78, + 32325 + ], + "KangaskhanFrontpic": [ + 74, + 19540 + ], + "KangaskhanPokedexEntry": [ + 110, + 21750 + ], + "KantoGrassWildMons": [ + 10, + 29300 + ], + "KantoMap": [ + 36, + 24936 + ], + "KantoWaterWildMons": [ + 10, + 30711 + ], + "KingdraBackpic": [ + 85, + 30086 + ], + "KingdraFrontpic": [ + 76, + 25971 + ], + "KingdraPokedexEntry": [ + 116, + 20433 + ], + "KinglerBackpic": [ + 86, + 29647 + ], + "KinglerFrontpic": [ + 76, + 17198 + ], + "KinglerPokedexEntry": [ + 110, + 20037 + ], + "KoffingBackpic": [ + 84, + 26383 + ], + "KoffingFrontpic": [ + 81, + 24231 + ], + "KoffingPokedexEntry": [ + 110, + 21110 + ], + "KrabbyBackpic": [ + 82, + 31759 + ], + "KrabbyFrontpic": [ + 81, + 19374 + ], + "KrabbyPokedexEntry": [ + 110, + 19933 + ], + "KrisBackpic": [ + 34, + 20182 + ], + "KrisCardPic": [ + 34, + 17813 + ], + "KrisPic": [ + 34, + 19385 + ], + "KrisSilhouetteGFX": [ + 94, + 24095 + ], + "Landmarks": [ + 114, + 26819 + ], + "LanturnBackpic": [ + 88, + 19293 + ], + "LanturnFrontpic": [ + 81, + 25666 + ], + "LanturnPokedexEntry": [ + 115, + 20958 + ], + "LaprasBackpic": [ + 87, + 26238 + ], + "LaprasFrontpic": [ + 77, + 18615 + ], + "LaprasPokedexEntry": [ + 115, + 16589 + ], + "LarvitarBackpic": [ + 87, + 31996 + ], + "LarvitarFrontpic": [ + 83, + 18460 + ], + "LarvitarPokedexEntry": [ + 116, + 22172 + ], + "LavaBubbleTileFrames": [ + 63, + 17925 + ], + "LeaderGFX": [ + 9, + 21891 + ], + "LedianBackpic": [ + 84, + 20272 + ], + "LedianFrontpic": [ + 79, + 28090 + ], + "LedianPokedexEntry": [ + 115, + 20384 + ], + "LedybaBackpic": [ + 83, + 21344 + ], + "LedybaFrontpic": [ + 78, + 26466 + ], + "LedybaPokedexEntry": [ + 115, + 20268 + ], + "LeechSeedSapsText": [ + 32, + 18446 + ], + "LickitungBackpic": [ + 87, + 19288 + ], + "LickitungFrontpic": [ + 76, + 25184 + ], + "LickitungPokedexEntry": [ + 110, + 21008 + ], + "LightScreenEffectText": [ + 32, + 21684 + ], + "LoadOpponentTrainerAndPokemon.resample": [ + 126, + 16418 + ], + "LoadOrientedFrontpic": [ + 20, + 21234 + ], + "LoadQuestionMarkPic.QuestionMarkLZ": [ + 119, + 24801 + ], + "LoadRandomBattleTowerMon.resample": [ + 126, + 16537 + ], + "LoafingAroundText": [ + 32, + 20249 + ], + "LookTownMapText": [ + 108, + 17549 + ], + "LostAgainstText": [ + 32, + 19129 + ], + "LugiaBackpic": [ + 88, + 21000 + ], + "LugiaFrontpic": [ + 72, + 25198 + ], + "LugiaPokedexEntry": [ + 116, + 22505 + ], + "MachampBackpic": [ + 81, + 23749 + ], + "MachampFrontpic": [ + 72, + 19205 + ], + "MachampPokedexEntry": [ + 110, + 16691 + ], + "MachokeBackpic": [ + 84, + 29391 + ], + "MachokeFrontpic": [ + 75, + 20751 + ], + "MachokePokedexEntry": [ + 110, + 16587 + ], + "MachopBackpic": [ + 86, + 25312 + ], + "MachopFrontpic": [ + 80, + 21481 + ], + "MachopPokedexEntry": [ + 110, + 16485 + ], + "MadeSubstituteText": [ + 32, + 21346 + ], + "MagazineBookshelfText": [ + 108, + 17315 + ], + "MagbyBackpic": [ + 87, + 26546 + ], + "MagbyFrontpic": [ + 80, + 18673 + ], + "MagbyPokedexEntry": [ + 116, + 21518 + ], + "MagcargoBackpic": [ + 83, + 29748 + ], + "MagcargoFrontpic": [ + 76, + 18818 + ], + "MagcargoPokedexEntry": [ + 116, + 19233 + ], + "MagikarpBackpic": [ + 86, + 19519 + ], + "MagikarpFrontpic": [ + 80, + 24148 + ], + "MagikarpPokedexEntry": [ + 115, + 16384 + ], + "MagmarBackpic": [ + 83, + 24590 + ], + "MagmarFrontpic": [ + 77, + 27266 + ], + "MagmarPokedexEntry": [ + 110, + 22932 + ], + "MagnemiteBackpic": [ + 86, + 32596 + ], + "MagnemiteFrontpic": [ + 86, + 31620 + ], + "MagnemitePokedexEntry": [ + 110, + 18077 + ], + "MagnetTrainBGTiles": [ + 35, + 19842 + ], + "MagnetTrainTilemap": [ + 35, + 20223 + ], + "MagnetonBackpic": [ + 82, + 29224 + ], + "MagnetonFrontpic": [ + 78, + 29741 + ], + "MagnetonPokedexEntry": [ + 110, + 18186 + ], + "MagnitudeText": [ + 32, + 22353 + ], + "MalePokegearPals": [ + 2, + 30505 + ], + "MankeyBackpic": [ + 83, + 24187 + ], + "MankeyFrontpic": [ + 81, + 17390 + ], + "MankeyPokedexEntry": [ + 96, + 28093 + ], + "MantineBackpic": [ + 89, + 20850 + ], + "MantineFrontpic": [ + 76, + 24395 + ], + "MantinePokedexEntry": [ + 116, + 20005 + ], + "MapGroupPointers": [ + 37, + 16384 + ], + "MapGroupRoofs": [ + 7, + 16417 + ], + "MapObjectPals": [ + 2, + 29801 + ], + "MareepBackpic": [ + 85, + 29028 + ], + "MareepFrontpic": [ + 81, + 27076 + ], + "MareepPokedexEntry": [ + 115, + 21841 + ], + "MarillBackpic": [ + 88, + 30890 + ], + "MarillFrontpic": [ + 82, + 19558 + ], + "MarillPokedexEntry": [ + 115, + 22277 + ], + "MarowakBackpic": [ + 85, + 18227 + ], + "MarowakFrontpic": [ + 77, + 30074 + ], + "MarowakPokedexEntry": [ + 110, + 20690 + ], + "MartSignText": [ + 108, + 17820 + ], + "Marts": [ + 5, + 24745 + ], + "MeganiumBackpic": [ + 87, + 18644 + ], + "MeganiumFrontpic": [ + 73, + 18692 + ], + "MeganiumPokedexEntry": [ + 115, + 19028 + ], + "MenuHeaders_UnownWalls": [ + 34, + 28373 + ], + "MeowthBackpic": [ + 87, + 18322 + ], + "MeowthFrontpic": [ + 80, + 29901 + ], + "MeowthPokedexEntry": [ + 96, + 27656 + ], + "MerchandiseShelfText": [ + 108, + 17522 + ], + "MetapodBackpic": [ + 88, + 31880 + ], + "MetapodFrontpic": [ + 87, + 22169 + ], + "MetapodPokedexEntry": [ + 96, + 23246 + ], + "MewBackpic": [ + 86, + 24977 + ], + "MewFrontpic": [ + 79, + 25107 + ], + "MewPokedexEntry": [ + 115, + 18709 + ], + "MewtwoBackpic": [ + 86, + 16384 + ], + "MewtwoFrontpic": [ + 73, + 29025 + ], + "MewtwoPokedexEntry": [ + 115, + 18606 + ], + "MiltankBackpic": [ + 84, + 21812 + ], + "MiltankFrontpic": [ + 79, + 20194 + ], + "MiltankPokedexEntry": [ + 116, + 21634 + ], + "MimicLearnedMoveText": [ + 32, + 21478 + ], + "MirrorMoveFailedText": [ + 32, + 21953 + ], + "MisdreavusBackpic": [ + 85, + 21149 + ], + "MisdreavusFrontpic": [ + 81, + 16384 + ], + "MisdreavusPokedexEntry": [ + 116, + 17153 + ], + "MistText": [ + 32, + 21259 + ], + "MobileAdapterCheckGFX": [ + 91, + 19619 + ], + "MobileAdapterPalettes": [ + 66, + 21447 + ], + "MobileCable1GFX": [ + 66, + 20935 + ], + "MobileCable2GFX": [ + 66, + 21191 + ], + "MobileCard2GFX": [ + 94, + 24655 + ], + "MobileCardGFX": [ + 94, + 23023 + ], + "MobileCardListGFX": [ + 94, + 29881 + ], + "MobileDialingFrameGFX": [ + 65, + 25876 + ], + "MobileDialingGFX": [ + 69, + 24602 + ], + "MobileDownArrowGFX": [ + 18, + 18635 + ], + "MobileMenuGFX": [ + 18, + 23564 + ], + "MobilePasswordAttrmap": [ + 92, + 26605 + ], + "MobilePasswordPalettes": [ + 92, + 23921 + ], + "MobilePhoneTilesGFX": [ + 62, + 21044 + ], + "MobileSelectGFX": [ + 95, + 18819 + ], + "MobileSplashScreenPalettes": [ + 91, + 18691 + ], + "MobileSystemSplashScreen_InitGFX.Attrmap": [ + 91, + 18331 + ], + "MobileSystemSplashScreen_InitGFX.Tilemap": [ + 91, + 17971 + ], + "MobileSystemSplashScreen_InitGFX.Tiles": [ + 91, + 16755 + ], + "MobileTradeAnim_GetFrontpic": [ + 66, + 16873 + ], + "MobileTradeAttrmapLZ": [ + 66, + 20647 + ], + "MobileTradeBGPalettes": [ + 66, + 20743 + ], + "MobileTradeGFX": [ + 66, + 19879 + ], + "MobileTradeLightsGFX": [ + 64, + 29346 + ], + "MobileTradeLightsPalettes": [ + 64, + 29410 + ], + "MobileTradeOB1Palettes": [ + 66, + 20807 + ], + "MobileTradeOB2Palettes": [ + 66, + 20871 + ], + "MobileTradeSpritesGFX": [ + 66, + 19751 + ], + "MobileTradeTilemapLZ": [ + 66, + 20455 + ], + "MobileUpArrowGFX": [ + 18, + 18627 + ], + "MoltresBackpic": [ + 83, + 16802 + ], + "MoltresFrontpic": [ + 73, + 25984 + ], + "MoltresPokedexEntry": [ + 115, + 18182 + ], + "MonMenuIcons": [ + 35, + 27332 + ], + "MoveDescriptions": [ + 11, + 19282 + ], + "MoveNames": [ + 114, + 24361 + ], + "Moves": [ + 16, + 23291 + ], + "MrMimeBackpic": [ + 88, + 20149 + ], + "MrMimeFrontpic": [ + 76, + 31370 + ], + "MrMimePokedexEntry": [ + 110, + 22506 + ], + "MukBackpic": [ + 86, + 18479 + ], + "MukFrontpic": [ + 75, + 25884 + ], + "MukPokedexEntry": [ + 110, + 18940 + ], + "MurkrowBackpic": [ + 86, + 20896 + ], + "MurkrowFrontpic": [ + 78, + 23815 + ], + "MurkrowPokedexEntry": [ + 116, + 16926 + ], + "Music": [ + 58, + 20590 + ], + "Music_AfterTheRivalFight": [ + 61, + 20775 + ], + "Music_AzaleaTown": [ + 61, + 22504 + ], + "Music_BattleTowerLobby": [ + 94, + 21643 + ], + "Music_BattleTowerTheme": [ + 94, + 18569 + ], + "Music_Bicycle": [ + 59, + 24857 + ], + "Music_BuenasPassword": [ + 94, + 16723 + ], + "Music_BugCatchingContest": [ + 61, + 31766 + ], + "Music_BurnedTower": [ + 61, + 27289 + ], + "Music_CeladonCity": [ + 61, + 17243 + ], + "Music_ChampionBattle": [ + 58, + 27073 + ], + "Music_CherrygroveCity": [ + 61, + 23299 + ], + "Music_Clair": [ + 7, + 31373 + ], + "Music_ContestResults": [ + 59, + 31550 + ], + "Music_Credits": [ + 7, + 29468 + ], + "Music_CrystalOpening": [ + 94, + 18152 + ], + "Music_DancingHall": [ + 59, + 31164 + ], + "Music_DarkCave": [ + 58, + 25278 + ], + "Music_DragonsDen": [ + 59, + 27880 + ], + "Music_EcruteakCity": [ + 59, + 29392 + ], + "Music_ElmsLab": [ + 58, + 24640 + ], + "Music_Evolution": [ + 60, + 18145 + ], + "Music_GameCorner": [ + 59, + 23917 + ], + "Music_GoldSilverOpening": [ + 61, + 28757 + ], + "Music_GoldSilverOpening2": [ + 61, + 29448 + ], + "Music_GoldenrodCity": [ + 58, + 29779 + ], + "Music_Gym": [ + 61, + 18606 + ], + "Music_GymLeaderVictory": [ + 61, + 17964 + ], + "Music_HallOfFame": [ + 60, + 17855 + ], + "Music_HealPokemon": [ + 60, + 18071 + ], + "Music_IndigoPlateau": [ + 59, + 26706 + ], + "Music_JohtoGymBattle": [ + 58, + 25869 + ], + "Music_JohtoTrainerBattle": [ + 61, + 24726 + ], + "Music_JohtoWildBattle": [ + 61, + 24005 + ], + "Music_JohtoWildBattleNight": [ + 61, + 24668 + ], + "Music_KantoGymBattle": [ + 59, + 18208 + ], + "Music_KantoTrainerBattle": [ + 59, + 18938 + ], + "Music_KantoWildBattle": [ + 59, + 20589 + ], + "Music_LakeOfRage": [ + 59, + 26309 + ], + "Music_LakeOfRageRocketRadio": [ + 59, + 30129 + ], + "Music_LavenderTown": [ + 59, + 30511 + ], + "Music_Lighthouse": [ + 59, + 25961 + ], + "Music_LookBeauty": [ + 59, + 28222 + ], + "Music_LookHiker": [ + 61, + 29713 + ], + "Music_LookKimonoGirl": [ + 61, + 31160 + ], + "Music_LookLass": [ + 59, + 22427 + ], + "Music_LookMysticalMan": [ + 94, + 17467 + ], + "Music_LookOfficer": [ + 59, + 22573 + ], + "Music_LookPokemaniac": [ + 58, + 32225 + ], + "Music_LookRival": [ + 61, + 20345 + ], + "Music_LookRocket": [ + 61, + 29858 + ], + "Music_LookSage": [ + 59, + 25438 + ], + "Music_LookYoungster": [ + 61, + 26307 + ], + "Music_MagnetTrain": [ + 59, + 30192 + ], + "Music_MainMenu": [ + 61, + 30973 + ], + "Music_MobileAdapter": [ + 7, + 31879 + ], + "Music_MobileAdapterMenu": [ + 94, + 16415 + ], + "Music_MobileCenter": [ + 94, + 22045 + ], + "Music_Mom": [ + 61, + 27634 + ], + "Music_MtMoon": [ + 59, + 23337 + ], + "Music_MtMoonSquare": [ + 61, + 18453 + ], + "Music_NationalPark": [ + 61, + 21736 + ], + "Music_NewBarkTown": [ + 58, + 29395 + ], + "Music_Nothing": [ + 58, + 20899 + ], + "Music_PalletTown": [ + 61, + 19212 + ], + "Music_PokeFluteChannel": [ + 61, + 31507 + ], + "Music_PokemonCenter": [ + 59, + 21958 + ], + "Music_PokemonChannel": [ + 59, + 25695 + ], + "Music_PokemonLullaby": [ + 61, + 28025 + ], + "Music_PokemonMarch": [ + 61, + 28195 + ], + "Music_PostCredits": [ + 51, + 32158 + ], + "Music_Printer": [ + 60, + 18429 + ], + "Music_ProfOak": [ + 61, + 19946 + ], + "Music_ProfOaksPokemonTalk": [ + 61, + 19615 + ], + "Music_RivalBattle": [ + 58, + 22351 + ], + "Music_RocketBattle": [ + 58, + 23407 + ], + "Music_RocketHideout": [ + 59, + 27509 + ], + "Music_RocketTheme": [ + 61, + 30317 + ], + "Music_Route1": [ + 59, + 16384 + ], + "Music_Route12": [ + 59, + 17670 + ], + "Music_Route2": [ + 59, + 22749 + ], + "Music_Route26": [ + 59, + 28594 + ], + "Music_Route29": [ + 60, + 17286 + ], + "Music_Route3": [ + 59, + 17098 + ], + "Music_Route30": [ + 59, + 31745 + ], + "Music_Route36": [ + 58, + 21737 + ], + "Music_Route37": [ + 59, + 26955 + ], + "Music_RuinsOfAlphInterior": [ + 58, + 32158 + ], + "Music_RuinsOfAlphRadio": [ + 59, + 28107 + ], + "Music_SSAqua": [ + 58, + 28057 + ], + "Music_ShowMeAround": [ + 59, + 23512 + ], + "Music_SproutTower": [ + 61, + 26996 + ], + "Music_SuccessfulCapture": [ + 61, + 17922 + ], + "Music_SuicuneBattle": [ + 94, + 19329 + ], + "Music_Surf": [ + 61, + 20874 + ], + "Music_TinTower": [ + 61, + 26641 + ], + "Music_TitleScreen": [ + 58, + 30728 + ], + "Music_TrainerVictory": [ + 58, + 32427 + ], + "Music_UnionCave": [ + 61, + 23648 + ], + "Music_VermilionCity": [ + 58, + 30326 + ], + "Music_VictoryRoad": [ + 61, + 27762 + ], + "Music_VioletCity": [ + 60, + 16384 + ], + "Music_ViridianCity": [ + 61, + 16384 + ], + "Music_WildPokemonVictory": [ + 61, + 17688 + ], + "MustRechargeText": [ + 32, + 19751 + ], + "MysteryGiftGFX": [ + 65, + 21080 + ], + "NPCTradeCableText": [ + 63, + 20347 + ], + "NPCTrades": [ + 63, + 20056 + ], + "NamingScreenGFX_Border": [ + 4, + 23735 + ], + "NamingScreenGFX_Cursor": [ + 4, + 23751 + ], + "NamingScreenGFX_MiddleLine": [ + 4, + 24165 + ], + "NamingScreenGFX_UnderLine": [ + 4, + 24173 + ], + "NatuBackpic": [ + 89, + 21902 + ], + "NatuFrontpic": [ + 88, + 23241 + ], + "NatuPokedexEntry": [ + 115, + 21629 + ], + "NewPokedexEntry": [ + 62, + 30839 + ], + "NewPokedexOrder": [ + 16, + 19808 + ], + "NidokingBackpic": [ + 81, + 21328 + ], + "NidokingFrontpic": [ + 72, + 22902 + ], + "NidokingPokedexEntry": [ + 96, + 25699 + ], + "NidoqueenBackpic": [ + 84, + 19113 + ], + "NidoqueenFrontpic": [ + 73, + 19748 + ], + "NidoqueenPokedexEntry": [ + 96, + 25390 + ], + "NidoranFBackpic": [ + 86, + 27318 + ], + "NidoranFFrontpic": [ + 80, + 31442 + ], + "NidoranFPokedexEntry": [ + 96, + 25163 + ], + "NidoranMBackpic": [ + 83, + 20933 + ], + "NidoranMFrontpic": [ + 80, + 26783 + ], + "NidoranMPokedexEntry": [ + 96, + 25490 + ], + "NidorinaBackpic": [ + 84, + 27891 + ], + "NidorinaFrontpic": [ + 77, + 23714 + ], + "NidorinaPokedexEntry": [ + 96, + 25270 + ], + "NidorinoBackpic": [ + 84, + 31259 + ], + "NidorinoFrontpic": [ + 75, + 28411 + ], + "NidorinoPokedexEntry": [ + 96, + 25597 + ], + "NinetalesBackpic": [ + 82, + 25791 + ], + "NinetalesFrontpic": [ + 72, + 20498 + ], + "NinetalesPokedexEntry": [ + 96, + 26124 + ], + "NoPPLeftText": [ + 32, + 20377 + ], + "NoctowlBackpic": [ + 87, + 16708 + ], + "NoctowlFrontpic": [ + 77, + 17131 + ], + "NoctowlPokedexEntry": [ + 115, + 20153 + ], + "NotVeryEffectiveText": [ + 32, + 20673 + ], + "NothingHappenedText": [ + 32, + 21721 + ], + "NurseAskHealText": [ + 108, + 16762 + ], + "NurseDayText": [ + 108, + 16427 + ], + "NurseGoodbyeText": [ + 108, + 16907 + ], + "NurseMornText": [ + 108, + 16384 + ], + "NurseNiteText": [ + 108, + 16463 + ], + "NursePokerusText": [ + 108, + 16961 + ], + "NurseReturnPokemonText": [ + 108, + 16855 + ], + "NurseTakePokemonText": [ + 108, + 16829 + ], + "OctilleryBackpic": [ + 88, + 27044 + ], + "OctilleryFrontpic": [ + 79, + 25706 + ], + "OctilleryPokedexEntry": [ + 116, + 19778 + ], + "OddishBackpic": [ + 88, + 32370 + ], + "OddishFrontpic": [ + 83, + 22572 + ], + "OddishPokedexEntry": [ + 96, + 26678 + ], + "OmanyteBackpic": [ + 84, + 16384 + ], + "OmanyteFrontpic": [ + 82, + 22257 + ], + "OmanytePokedexEntry": [ + 115, + 17345 + ], + "OmanytePuzzleLZ": [ + 56, + 23707 + ], + "OmastarBackpic": [ + 74, + 32337 + ], + "OmastarFrontpic": [ + 78, + 27781 + ], + "OmastarPokedexEntry": [ + 115, + 17454 + ], + "OneHitKOText": [ + 32, + 20631 + ], + "OnixBackpic": [ + 84, + 26005 + ], + "OnixFrontpic": [ + 75, + 19015 + ], + "OnixPokedexEntry": [ + 110, + 19608 + ], + "OverworldSprites": [ + 5, + 18230 + ], + "PCMailGFX": [ + 56, + 29853 + ], + "PackGFX": [ + 4, + 20502 + ], + "PackMenuGFX": [ + 4, + 19222 + ], + "PadFrontpic": [ + 20, + 21163 + ], + "PalPacket_Pack": [ + 2, + 23606 + ], + "ParalyzedText": [ + 32, + 21848 + ], + "ParasBackpic": [ + 88, + 16384 + ], + "ParasFrontpic": [ + 81, + 20842 + ], + "ParasPokedexEntry": [ + 96, + 27007 + ], + "ParasectBackpic": [ + 80, + 32454 + ], + "ParasectFrontpic": [ + 76, + 30607 + ], + "ParasectPokedexEntry": [ + 96, + 27106 + ], + "PartyMenuOBPals": [ + 2, + 30337 + ], + "PasswordBottomTilemap": [ + 92, + 25885 + ], + "PasswordShiftTilemap": [ + 92, + 26105 + ], + "PasswordTopTilemap": [ + 92, + 25745 + ], + "PerishCountText": [ + 32, + 18532 + ], + "PersianBackpic": [ + 84, + 22580 + ], + "PersianFrontpic": [ + 76, + 19617 + ], + "PersianPokedexEntry": [ + 96, + 27768 + ], + "PhanpyBackpic": [ + 88, + 24347 + ], + "PhanpyFrontpic": [ + 83, + 17633 + ], + "PhanpyPokedexEntry": [ + 116, + 20536 + ], + "PhoneContacts": [ + 36, + 17503 + ], + "PhoneOutOfAreaScript": [ + 36, + 18007 + ], + "PhoneScript_JustTalkToThem": [ + 36, + 18016 + ], + "PhoneTilemapRLE": [ + 36, + 21898 + ], + "PichuAnimatedMobileGFX": [ + 92, + 19734 + ], + "PichuBackpic": [ + 88, + 18142 + ], + "PichuBorderMobileBGPalettes": [ + 69, + 29518 + ], + "PichuBorderMobileGFX": [ + 92, + 22600 + ], + "PichuBorderMobileOBPalettes": [ + 69, + 29454 + ], + "PichuBorderMobileTilemapAttrmap": [ + 69, + 29526 + ], + "PichuFrontpic": [ + 84, + 19500 + ], + "PichuPokedexEntry": [ + 115, + 21059 + ], + "PictureBookshelfText": [ + 108, + 17272 + ], + "PidgeotBackpic": [ + 86, + 23965 + ], + "PidgeotFrontpic": [ + 74, + 23325 + ], + "PidgeotPokedexEntry": [ + 96, + 24023 + ], + "PidgeottoBackpic": [ + 88, + 19579 + ], + "PidgeottoFrontpic": [ + 78, + 25807 + ], + "PidgeottoPokedexEntry": [ + 96, + 23925 + ], + "PidgeyBackpic": [ + 84, + 16776 + ], + "PidgeyFrontpic": [ + 80, + 31948 + ], + "PidgeyPokedexEntry": [ + 96, + 23802 + ], + "PikachuBackpic": [ + 85, + 28321 + ], + "PikachuFrontpic": [ + 80, + 17533 + ], + "PikachuPokedexEntry": [ + 96, + 24749 + ], + "PiloswineBackpic": [ + 89, + 21277 + ], + "PiloswineFrontpic": [ + 78, + 28437 + ], + "PiloswinePokedexEntry": [ + 116, + 19457 + ], + "PinecoBackpic": [ + 87, + 21850 + ], + "PinecoFrontpic": [ + 81, + 18880 + ], + "PinecoPokedexEntry": [ + 116, + 17584 + ], + "PinsirBackpic": [ + 82, + 26657 + ], + "PinsirFrontpic": [ + 75, + 31711 + ], + "PinsirPokedexEntry": [ + 110, + 23035 + ], + "PlaceUnownPrinterFrontpic": [ + 5, + 28076 + ], + "PlayerHitTimesText": [ + 32, + 21221 + ], + "PokeComNurseDayText": [ + 108, + 16598 + ], + "PokeComNurseMornText": [ + 108, + 16522 + ], + "PokeComNurseNiteText": [ + 108, + 16667 + ], + "PokeComNursePokerusText": [ + 108, + 17110 + ], + "PokecenterSignText": [ + 108, + 17791 + ], + "PokedexCursorPalette": [ + 2, + 20418 + ], + "PokedexDataPointerTable": [ + 17, + 17272 + ], + "PokedexLZ": [ + 16, + 21774 + ], + "PokedexQuestionMarkPalette": [ + 2, + 20410 + ], + "PokedexSlowpokeLZ": [ + 16, + 22192 + ], + "PokegearGFX": [ + 119, + 25316 + ], + "PokegearPhoneIconGFX": [ + 62, + 20260 + ], + "PokegearSpritesGFX": [ + 36, + 21725 + ], + "PokemonCries": [ + 60, + 26503 + ], + "PokemonFellFromTreeText": [ + 32, + 18296 + ], + "PokemonNames": [ + 20, + 29572 + ], + "PokemonNewsGFX": [ + 95, + 26366 + ], + "PokemonNewsPalettes": [ + 95, + 28662 + ], + "PokemonNewsTileAttrmap": [ + 95, + 27534 + ], + "PokemonPalettes": [ + 2, + 26830 + ], + "PokemonPicPointers": [ + 72, + 16384 + ], + "PokemonProfPic": [ + 86, + 16734 + ], + "PolitoedBackpic": [ + 88, + 22405 + ], + "PolitoedFrontpic": [ + 76, + 22806 + ], + "PolitoedPokedexEntry": [ + 115, + 22603 + ], + "PoliwagBackpic": [ + 89, + 17351 + ], + "PoliwagFrontpic": [ + 84, + 25626 + ], + "PoliwagPokedexEntry": [ + 96, + 28561 + ], + "PoliwhirlBackpic": [ + 86, + 24641 + ], + "PoliwhirlFrontpic": [ + 77, + 20086 + ], + "PoliwhirlPokedexEntry": [ + 96, + 28669 + ], + "PoliwrathBackpic": [ + 84, + 30141 + ], + "PoliwrathFrontpic": [ + 75, + 22474 + ], + "PoliwrathPokedexEntry": [ + 96, + 28774 + ], + "PonytaBackpic": [ + 84, + 24868 + ], + "PonytaFrontpic": [ + 78, + 23150 + ], + "PonytaPokedexEntry": [ + 110, + 17650 + ], + "Porygon2Backpic": [ + 87, + 21211 + ], + "Porygon2Frontpic": [ + 80, + 30417 + ], + "Porygon2PokedexEntry": [ + 116, + 20748 + ], + "PorygonBackpic": [ + 87, + 32294 + ], + "PorygonFrontpic": [ + 80, + 27307 + ], + "PorygonPokedexEntry": [ + 115, + 17235 + ], + "PredefPals": [ + 2, + 24054 + ], + "PrepMonFrontpic": [ + 0, + 14214 + ], + "PresentFailedText": [ + 32, + 22587 + ], + "PrimeapeBackpic": [ + 85, + 24393 + ], + "PrimeapeFrontpic": [ + 74, + 31456 + ], + "PrimeapePokedexEntry": [ + 96, + 28217 + ], + "ProtectedByMistText": [ + 32, + 21281 + ], + "ProtectedByText": [ + 32, + 21929 + ], + "ProtectedItselfText": [ + 32, + 22117 + ], + "ProtectingItselfText": [ + 32, + 22138 + ], + "PsyduckBackpic": [ + 85, + 27965 + ], + "PsyduckFrontpic": [ + 81, + 22782 + ], + "PsyduckPokedexEntry": [ + 96, + 27886 + ], + "PupitarBackpic": [ + 84, + 20657 + ], + "PupitarFrontpic": [ + 82, + 23149 + ], + "PupitarPokedexEntry": [ + 116, + 22281 + ], + "PutACurseText": [ + 32, + 22077 + ], + "PuzzlePieceBorderData.TileBordersGFX": [ + 56, + 22307 + ], + "QuagsireBackpic": [ + 87, + 22803 + ], + "QuagsireFrontpic": [ + 79, + 32183 + ], + "QuagsirePokedexEntry": [ + 116, + 16605 + ], + "QuestionEmote": [ + 65, + 17296 + ], + "QuilavaBackpic": [ + 83, + 25393 + ], + "QuilavaFrontpic": [ + 73, + 32028 + ], + "QuilavaPokedexEntry": [ + 115, + 19251 + ], + "QwilfishBackpic": [ + 84, + 17558 + ], + "QwilfishFrontpic": [ + 82, + 29649 + ], + "QwilfishPokedexEntry": [ + 116, + 18353 + ], + "RadioTilemapRLE": [ + 36, + 21773 + ], + "RageBuildingText": [ + 32, + 20723 + ], + "RaichuBackpic": [ + 84, + 29766 + ], + "RaichuFrontpic": [ + 74, + 30565 + ], + "RaichuPokedexEntry": [ + 96, + 24855 + ], + "RaikouBackpic": [ + 89, + 16627 + ], + "RaikouFrontpic": [ + 72, + 24064 + ], + "RaikouPokedexEntry": [ + 116, + 21867 + ], + "RapidashBackpic": [ + 83, + 22976 + ], + "RapidashFrontpic": [ + 73, + 17624 + ], + "RapidashPokedexEntry": [ + 110, + 17758 + ], + "RaticateBackpic": [ + 85, + 26183 + ], + "RaticateFrontpic": [ + 76, + 21213 + ], + "RaticatePokedexEntry": [ + 96, + 24227 + ], + "RattataBackpic": [ + 86, + 30308 + ], + "RattataFrontpic": [ + 80, + 22553 + ], + "RattataPokedexEntry": [ + 96, + 24125 + ], + "ReadTileToAnimBuffer": [ + 63, + 18082 + ], + "ReceivedItemText": [ + 108, + 17992 + ], + "RecoilText": [ + 32, + 21325 + ], + "RecoveredUsingText": [ + 32, + 19394 + ], + "ReflectEffectText": [ + 32, + 21703 + ], + "RegainedHealthText": [ + 32, + 20492 + ], + "RegisteredNumber1Text": [ + 108, + 18884 + ], + "RegisteredNumber2Text": [ + 108, + 18913 + ], + "ReleasedByText": [ + 32, + 22372 + ], + "RemoraidBackpic": [ + 85, + 29381 + ], + "RemoraidFrontpic": [ + 84, + 25247 + ], + "RemoraidPokedexEntry": [ + 116, + 19667 + ], + "RestedText": [ + 32, + 20457 + ], + "RhydonBackpic": [ + 83, + 18047 + ], + "RhydonFrontpic": [ + 74, + 20510 + ], + "RhydonPokedexEntry": [ + 110, + 21445 + ], + "RhyhornBackpic": [ + 87, + 30200 + ], + "RhyhornFrontpic": [ + 74, + 22399 + ], + "RhyhornPokedexEntry": [ + 110, + 21341 + ], + "RoamMaps": [ + 10, + 25615 + ], + "RockMonMaps": [ + 46, + 17093 + ], + "RoofPals": [ + 2, + 30057 + ], + "Roofs": [ + 7, + 16444 + ], + "RotateUnownFrontpic": [ + 56, + 16384 + ], + "SFX": [ + 58, + 21116 + ], + "SadEmote": [ + 65, + 17424 + ], + "SafeguardProtectText": [ + 32, + 22323 + ], + "SandshrewBackpic": [ + 83, + 26192 + ], + "SandshrewFrontpic": [ + 80, + 30930 + ], + "SandshrewPokedexEntry": [ + 96, + 24964 + ], + "SandslashBackpic": [ + 83, + 19286 + ], + "SandslashFrontpic": [ + 75, + 29244 + ], + "SandslashPokedexEntry": [ + 96, + 25063 + ], + "SandstormBrewedText": [ + 32, + 22244 + ], + "SandstormHitsText": [ + 32, + 18509 + ], + "ScizorBackpic": [ + 85, + 22233 + ], + "ScizorFrontpic": [ + 73, + 21852 + ], + "ScizorPokedexEntry": [ + 116, + 18465 + ], + "ScrollTileDown": [ + 63, + 17258 + ], + "ScrollTileLeft": [ + 63, + 17191 + ], + "ScrollTileRight": [ + 63, + 17211 + ], + "ScrollTileRightLeft": [ + 63, + 17161 + ], + "ScrollTileUp": [ + 63, + 17231 + ], + "ScytherBackpic": [ + 85, + 18962 + ], + "ScytherFrontpic": [ + 75, + 23333 + ], + "ScytherPokedexEntry": [ + 110, + 22609 + ], + "SeadraBackpic": [ + 83, + 19698 + ], + "SeadraFrontpic": [ + 80, + 18103 + ], + "SeadraPokedexEntry": [ + 110, + 21944 + ], + "SeakingBackpic": [ + 85, + 27253 + ], + "SeakingFrontpic": [ + 75, + 25036 + ], + "SeakingPokedexEntry": [ + 110, + 22160 + ], + "SeelBackpic": [ + 87, + 22486 + ], + "SeelFrontpic": [ + 79, + 16384 + ], + "SeelPokedexEntry": [ + 110, + 18623 + ], + "SelectStartGFX": [ + 71, + 22142 + ], + "SentAllToMomText": [ + 32, + 19005 + ], + "SentHalfToMomText": [ + 32, + 18986 + ], + "SentSomeToMomText": [ + 32, + 18940 + ], + "SentretBackpic": [ + 88, + 27829 + ], + "SentretFrontpic": [ + 85, + 17122 + ], + "SentretPokedexEntry": [ + 115, + 19819 + ], + "SharedPainText": [ + 32, + 20763 + ], + "ShedLeechSeedText": [ + 32, + 22394 + ], + "ShellderBackpic": [ + 83, + 27381 + ], + "ShellderFrontpic": [ + 82, + 24038 + ], + "ShellderPokedexEntry": [ + 110, + 19048 + ], + "ShockEmote": [ + 65, + 17232 + ], + "Shrink1Pic": [ + 19, + 21065 + ], + "Shrink2Pic": [ + 19, + 21209 + ], + "ShuckleBackpic": [ + 89, + 17830 + ], + "ShuckleFrontpic": [ + 84, + 24489 + ], + "ShucklePokedexEntry": [ + 116, + 18574 + ], + "SkarmoryBackpic": [ + 88, + 31634 + ], + "SkarmoryFrontpic": [ + 74, + 27888 + ], + "SkarmoryPokedexEntry": [ + 116, + 20108 + ], + "SketchedText": [ + 32, + 20803 + ], + "SkiploomBackpic": [ + 87, + 28692 + ], + "SkiploomFrontpic": [ + 81, + 26608 + ], + "SkiploomPokedexEntry": [ + 115, + 22824 + ], + "SleepEmote": [ + 65, + 17616 + ], + "Slots1LZ": [ + 36, + 29773 + ], + "Slots2LZ": [ + 36, + 30157 + ], + "Slots3LZ": [ + 36, + 30765 + ], + "SlotsTilemap": [ + 36, + 29533 + ], + "SlowbroBackpic": [ + 86, + 21582 + ], + "SlowbroFrontpic": [ + 74, + 24245 + ], + "SlowbroPokedexEntry": [ + 110, + 17973 + ], + "SlowkingBackpic": [ + 82, + 30496 + ], + "SlowkingFrontpic": [ + 75, + 17265 + ], + "SlowkingPokedexEntry": [ + 116, + 17042 + ], + "SlowpokeBackpic": [ + 89, + 18537 + ], + "SlowpokeFrontpic": [ + 82, + 18200 + ], + "SlowpokePokedexEntry": [ + 110, + 17875 + ], + "SlugmaBackpic": [ + 87, + 17354 + ], + "SlugmaFrontpic": [ + 82, + 17293 + ], + "SlugmaPokedexEntry": [ + 116, + 19128 + ], + "SmeargleBackpic": [ + 85, + 23314 + ], + "SmeargleFrontpic": [ + 78, + 21817 + ], + "SmearglePokedexEntry": [ + 116, + 20960 + ], + "SmoochumBackpic": [ + 88, + 17853 + ], + "SmoochumFrontpic": [ + 82, + 18653 + ], + "SmoochumPokedexEntry": [ + 116, + 21291 + ], + "SneaselBackpic": [ + 86, + 20209 + ], + "SneaselFrontpic": [ + 78, + 18446 + ], + "SneaselPokedexEntry": [ + 116, + 18810 + ], + "SnorlaxBackpic": [ + 82, + 32597 + ], + "SnorlaxFrontpic": [ + 78, + 19802 + ], + "SnorlaxPokedexEntry": [ + 115, + 17871 + ], + "SnubbullBackpic": [ + 86, + 29316 + ], + "SnubbullFrontpic": [ + 79, + 18933 + ], + "SnubbullPokedexEntry": [ + 116, + 18144 + ], + "SpawnPoints": [ + 5, + 21163 + ], + "SpearowBackpic": [ + 87, + 25619 + ], + "SpearowFrontpic": [ + 80, + 23619 + ], + "SpearowPokedexEntry": [ + 96, + 24323 + ], + "SpecialPhoneCallList": [ + 36, + 17959 + ], + "SpikesText": [ + 32, + 22161 + ], + "SpinarakBackpic": [ + 89, + 16384 + ], + "SpinarakFrontpic": [ + 87, + 28994 + ], + "SpinarakPokedexEntry": [ + 115, + 20498 + ], + "SpiteEffectText": [ + 32, + 20863 + ], + "SpriteMons": [ + 5, + 17557 + ], + "SquirtleBackpic": [ + 89, + 17591 + ], + "SquirtleFrontpic": [ + 81, + 23266 + ], + "SquirtlePokedexEntry": [ + 96, + 22822 + ], + "Stadium2N64Attrmap": [ + 92, + 29975 + ], + "Stadium2N64GFX": [ + 92, + 28447 + ], + "Stadium2N64Tilemap": [ + 92, + 29615 + ], + "StandingTileFrame": [ + 63, + 18035 + ], + "StandingTileFrame8": [ + 63, + 17151 + ], + "StantlerBackpic": [ + 85, + 22954 + ], + "StantlerFrontpic": [ + 76, + 20415 + ], + "StantlerPokedexEntry": [ + 116, + 20852 + ], + "StarmieBackpic": [ + 83, + 32501 + ], + "StarmieFrontpic": [ + 78, + 25144 + ], + "StarmiePokedexEntry": [ + 110, + 22390 + ], + "StartPerishText": [ + 32, + 22210 + ], + "StartedNightmareText": [ + 32, + 22027 + ], + "StaryuBackpic": [ + 88, + 19864 + ], + "StaryuFrontpic": [ + 80, + 28868 + ], + "StaryuPokedexEntry": [ + 110, + 22274 + ], + "StatsScreenPagePals": [ + 2, + 20306 + ], + "StatsScreenPageTilesGFX": [ + 62, + 18864 + ], + "StatsScreen_PlaceFrontpic": [ + 19, + 25126 + ], + "StdScripts": [ + 47, + 16384 + ], + "SteelixBackpic": [ + 84, + 22196 + ], + "SteelixFrontpic": [ + 74, + 16585 + ], + "SteelixPokedexEntry": [ + 116, + 18044 + ], + "StoleText": [ + 32, + 21978 + ], + "StoringEnergyText": [ + 32, + 20078 + ], + "SubFadedText": [ + 32, + 21456 + ], + "SubTookDamageText": [ + 32, + 21421 + ], + "SuckedHealthText": [ + 32, + 21008 + ], + "SudowoodoBackpic": [ + 85, + 16384 + ], + "SudowoodoFrontpic": [ + 80, + 16959 + ], + "SudowoodoPokedexEntry": [ + 115, + 22489 + ], + "SuicuneBackpic": [ + 82, + 30073 + ], + "SuicuneFrontpic": [ + 72, + 30736 + ], + "SuicunePokedexEntry": [ + 116, + 22082 + ], + "SunGotBrightText": [ + 32, + 22456 + ], + "SunfloraBackpic": [ + 83, + 17218 + ], + "SunfloraFrontpic": [ + 79, + 21443 + ], + "SunfloraPokedexEntry": [ + 115, + 23279 + ], + "SunkernBackpic": [ + 87, + 25929 + ], + "SunkernFrontpic": [ + 84, + 30888 + ], + "SunkernPokedexEntry": [ + 115, + 23175 + ], + "SuperEffectiveText": [ + 32, + 20650 + ], + "SwarmGrassWildMons": [ + 10, + 30928 + ], + "SwarmWaterWildMons": [ + 10, + 31023 + ], + "SwinubBackpic": [ + 85, + 32544 + ], + "SwinubFrontpic": [ + 87, + 31698 + ], + "SwinubPokedexEntry": [ + 116, + 19341 + ], + "TMHMMoves": [ + 4, + 22138 + ], + "TVText": [ + 108, + 17598 + ], + "TangelaBackpic": [ + 89, + 20163 + ], + "TangelaFrontpic": [ + 79, + 18300 + ], + "TangelaPokedexEntry": [ + 110, + 21644 + ], + "TaurosBackpic": [ + 89, + 19472 + ], + "TaurosFrontpic": [ + 72, + 27437 + ], + "TaurosPokedexEntry": [ + 110, + 23151 + ], + "TeamRocketOathText": [ + 108, + 17369 + ], + "TeddiursaBackpic": [ + 88, + 24071 + ], + "TeddiursaFrontpic": [ + 83, + 28171 + ], + "TeddiursaPokedexEntry": [ + 116, + 18906 + ], + "TentacoolBackpic": [ + 88, + 16976 + ], + "TentacoolFrontpic": [ + 82, + 24480 + ], + "TentacoolPokedexEntry": [ + 110, + 17105 + ], + "TentacruelBackpic": [ + 84, + 28266 + ], + "TentacruelFrontpic": [ + 77, + 27969 + ], + "TentacruelPokedexEntry": [ + 110, + 17207 + ], + "Text_BallCaught": [ + 113, + 23319 + ], + "Text_BattleEffectActivate": [ + 112, + 19654 + ], + "Text_BattleFoeEffectActivate": [ + 112, + 19691 + ], + "Text_BattleUser": [ + 112, + 19726 + ], + "Text_BreedHuh": [ + 112, + 19888 + ], + "Text_Gained": [ + 112, + 17052 + ], + "Text_MoveForgetCount": [ + 113, + 22336 + ], + "Text_NPCTraded": [ + 111, + 21545 + ], + "Text_PlayedPokeFlute": [ + 113, + 23620 + ], + "TheEndGFX": [ + 50, + 32046 + ], + "TiedAgainstText": [ + 32, + 18923 + ], + "TilesetBGPalette": [ + 2, + 29465 + ], + "Tilesets": [ + 19, + 21910 + ], + "TimeFishGroups": [ + 36, + 26223 + ], + "TitleCrystalGFX": [ + 67, + 31982 + ], + "TitleLogoGFX": [ + 67, + 29478 + ], + "TitleScreenPalettes": [ + 67, + 32478 + ], + "TitleSuicuneGFX": [ + 67, + 28486 + ], + "TogepiBackpic": [ + 88, + 25442 + ], + "TogepiFrontpic": [ + 87, + 19609 + ], + "TogepiPokedexEntry": [ + 115, + 21398 + ], + "TogeticBackpic": [ + 85, + 17491 + ], + "TogeticFrontpic": [ + 78, + 31681 + ], + "TogeticPokedexEntry": [ + 115, + 21506 + ], + "TooWeakSubText": [ + 32, + 21389 + ], + "TookAimText": [ + 32, + 20790 + ], + "TookDownWithItText": [ + 32, + 20698 + ], + "TotodileBackpic": [ + 86, + 28984 + ], + "TotodileFrontpic": [ + 80, + 19809 + ], + "TotodilePokedexEntry": [ + 115, + 19476 + ], + "TownMapGFX": [ + 62, + 19360 + ], + "TownMapPals.PalMap": [ + 36, + 24395 + ], + "TradeAnim_AnimateFrontpic": [ + 10, + 21639 + ], + "TradeAnim_GetFrontpic": [ + 10, + 21649 + ], + "TradeAnim_ShowFrontpic": [ + 10, + 21699 + ], + "TradeAnim_ShowGetmonFrontpic": [ + 10, + 21696 + ], + "TradeAnim_ShowGivemonFrontpic": [ + 10, + 21691 + ], + "TradeArrowLeftGFX": [ + 10, + 22827 + ], + "TradeArrowRightGFX": [ + 10, + 22811 + ], + "TradeBallGFX": [ + 10, + 23259 + ], + "TradeBubbleGFX": [ + 10, + 22875 + ], + "TradeCableGFX": [ + 10, + 22843 + ], + "TradeGameBoyLZ": [ + 10, + 22939 + ], + "TradeGameBoyTilemap": [ + 10, + 22727 + ], + "TradeLinkTubeTilemap": [ + 10, + 22775 + ], + "TradePoofGFX": [ + 10, + 23355 + ], + "TradeTexts": [ + 63, + 20307 + ], + "TradedForText": [ + 63, + 20352 + ], + "TrainerCardGFX": [ + 34, + 18373 + ], + "TrainerCard_JohtoBadgesOAM": [ + 9, + 21705 + ], + "TrainerClassAttributes": [ + 14, + 21916 + ], + "TrainerClassNames": [ + 11, + 16879 + ], + "TrainerEncounterMusic": [ + 58, + 20519 + ], + "TrainerGroups": [ + 14, + 22937 + ], + "TrainerPalettes": [ + 2, + 28878 + ], + "TrainerPicPointers": [ + 74, + 16384 + ], + "Trainers": [ + 14, + 23071 + ], + "TransformedText": [ + 32, + 21657 + ], + "TransformedTypeText": [ + 32, + 21586 + ], + "TrashCanText": [ + 108, + 17702 + ], + "TreeMonMaps": [ + 46, + 16990 + ], + "TreeMons": [ + 46, + 17128 + ], + "TurnedAwayText": [ + 32, + 20308 + ], + "TypeMatchups": [ + 13, + 19377 + ], + "TypeNames": [ + 20, + 18811 + ], + "TyphlosionBackpic": [ + 84, + 18337 + ], + "TyphlosionFrontpic": [ + 72, + 31821 + ], + "TyphlosionPokedexEntry": [ + 115, + 19360 + ], + "TyranitarBackpic": [ + 83, + 18873 + ], + "TyranitarFrontpic": [ + 73, + 24963 + ], + "TyranitarPokedexEntry": [ + 116, + 22395 + ], + "TyrogueBackpic": [ + 85, + 21511 + ], + "TyrogueFrontpic": [ + 81, + 26138 + ], + "TyroguePokedexEntry": [ + 116, + 21067 + ], + "UmbreonBackpic": [ + 86, + 20553 + ], + "UmbreonFrontpic": [ + 79, + 22062 + ], + "UmbreonPokedexEntry": [ + 116, + 16815 + ], + "UnaffectedText": [ + 32, + 20577 + ], + "UnleashedEnergyText": [ + 32, + 20100 + ], + "UnownABackpic": [ + 89, + 25801 + ], + "UnownAFrontpic": [ + 89, + 22105 + ], + "UnownAnimationIdlePointers": [ + 52, + 25553 + ], + "UnownAnimationPointers": [ + 52, + 25129 + ], + "UnownBBackpic": [ + 89, + 25164 + ], + "UnownBFrontpic": [ + 89, + 18302 + ], + "UnownBitmasksPointers": [ + 52, + 31443 + ], + "UnownCBackpic": [ + 89, + 23263 + ], + "UnownCFrontpic": [ + 87, + 30500 + ], + "UnownDBackpic": [ + 88, + 32614 + ], + "UnownDFrontpic": [ + 84, + 21427 + ], + "UnownEBackpic": [ + 89, + 24893 + ], + "UnownEFrontpic": [ + 75, + 32526 + ], + "UnownFBackpic": [ + 81, + 32615 + ], + "UnownFFrontpic": [ + 89, + 21695 + ], + "UnownFont": [ + 119, + 16384 + ], + "UnownFramesPointers": [ + 54, + 22953 + ], + "UnownGBackpic": [ + 89, + 23971 + ], + "UnownGFrontpic": [ + 88, + 21845 + ], + "UnownHBackpic": [ + 87, + 32591 + ], + "UnownHFrontpic": [ + 88, + 29635 + ], + "UnownIBackpic": [ + 89, + 26907 + ], + "UnownIFrontpic": [ + 89, + 24135 + ], + "UnownJBackpic": [ + 89, + 25033 + ], + "UnownJFrontpic": [ + 89, + 20621 + ], + "UnownKBackpic": [ + 89, + 26045 + ], + "UnownKFrontpic": [ + 89, + 16870 + ], + "UnownLBackpic": [ + 89, + 26391 + ], + "UnownLFrontpic": [ + 89, + 21066 + ], + "UnownMBackpic": [ + 89, + 25924 + ], + "UnownMFrontpic": [ + 89, + 21487 + ], + "UnownNBackpic": [ + 89, + 25678 + ], + "UnownNFrontpic": [ + 89, + 19933 + ], + "UnownOBackpic": [ + 89, + 25295 + ], + "UnownOFrontpic": [ + 88, + 28090 + ], + "UnownPBackpic": [ + 89, + 26813 + ], + "UnownPFrontpic": [ + 89, + 23078 + ], + "UnownPicPointers": [ + 73, + 16384 + ], + "UnownPokedexEntry": [ + 116, + 17267 + ], + "UnownPuzzleCursorGFX": [ + 56, + 22469 + ], + "UnownPuzzleStartCancelLZ": [ + 56, + 22533 + ], + "UnownQBackpic": [ + 89, + 26606 + ], + "UnownQFrontpic": [ + 89, + 22889 + ], + "UnownRBackpic": [ + 89, + 27001 + ], + "UnownRFrontpic": [ + 89, + 24747 + ], + "UnownSBackpic": [ + 89, + 24601 + ], + "UnownSFrontpic": [ + 88, + 27307 + ], + "UnownTBackpic": [ + 89, + 26162 + ], + "UnownTFrontpic": [ + 88, + 28350 + ], + "UnownUBackpic": [ + 89, + 26499 + ], + "UnownUFrontpic": [ + 89, + 22506 + ], + "UnownVBackpic": [ + 89, + 24298 + ], + "UnownVFrontpic": [ + 88, + 24622 + ], + "UnownWBackpic": [ + 89, + 25552 + ], + "UnownWFrontpic": [ + 87, + 29598 + ], + "UnownWalls": [ + 34, + 28348 + ], + "UnownXBackpic": [ + 89, + 26278 + ], + "UnownXFrontpic": [ + 89, + 19703 + ], + "UnownYBackpic": [ + 89, + 26711 + ], + "UnownYFrontpic": [ + 89, + 23798 + ], + "UnownZBackpic": [ + 89, + 25425 + ], + "UnownZFrontpic": [ + 87, + 31399 + ], + "UnusedMobilePulsePalettes": [ + 66, + 20727 + ], + "UnusedRivalLossText": [ + 32, + 19023 + ], + "UnusedRivalWinText": [ + 32, + 19091 + ], + "Unused_HOF_AnimateAlignedFrontpic": [ + 52, + 18025 + ], + "UrsaringBackpic": [ + 88, + 23518 + ], + "UrsaringFrontpic": [ + 77, + 20814 + ], + "UrsaringPokedexEntry": [ + 116, + 19009 + ], + "UsedBindText": [ + 32, + 19985 + ], + "VaporeonBackpic": [ + 88, + 16680 + ], + "VaporeonFrontpic": [ + 78, + 16384 + ], + "VaporeonPokedexEntry": [ + 115, + 16932 + ], + "VenomothBackpic": [ + 82, + 20910 + ], + "VenomothFrontpic": [ + 76, + 22010 + ], + "VenomothPokedexEntry": [ + 96, + 27327 + ], + "VenonatBackpic": [ + 85, + 31493 + ], + "VenonatFrontpic": [ + 81, + 24713 + ], + "VenonatPokedexEntry": [ + 96, + 27219 + ], + "VenusaurBackpic": [ + 83, + 16384 + ], + "VenusaurFrontpic": [ + 72, + 28545 + ], + "VenusaurPokedexEntry": [ + 96, + 22387 + ], + "VictreebelBackpic": [ + 87, + 18000 + ], + "VictreebelFrontpic": [ + 74, + 29673 + ], + "VictreebelPokedexEntry": [ + 110, + 17006 + ], + "VileplumeBackpic": [ + 85, + 19328 + ], + "VileplumeFrontpic": [ + 77, + 25855 + ], + "VileplumePokedexEntry": [ + 96, + 26904 + ], + "VoltorbBackpic": [ + 89, + 20392 + ], + "VoltorbFrontpic": [ + 88, + 20718 + ], + "VoltorbPokedexEntry": [ + 110, + 20144 + ], + "VulpixBackpic": [ + 85, + 26540 + ], + "VulpixFrontpic": [ + 77, + 30774 + ], + "VulpixPokedexEntry": [ + 96, + 26011 + ], + "WaitTileAnimation": [ + 63, + 17150 + ], + "WantsToBattleText": [ + 32, + 18345 + ], + "WartortleBackpic": [ + 85, + 25110 + ], + "WartortleFrontpic": [ + 77, + 17874 + ], + "WartortlePokedexEntry": [ + 96, + 22931 + ], + "WasBurnedText": [ + 32, + 21052 + ], + "WasDefrostedText": [ + 32, + 22059 + ], + "WasDisabledText": [ + 32, + 21533 + ], + "WasFrozenText": [ + 32, + 21085 + ], + "WasPoisonedText": [ + 32, + 20949 + ], + "WasSeededText": [ + 32, + 21496 + ], + "WaveSamples": [ + 58, + 19890 + ], + "WeedleBackpic": [ + 88, + 29379 + ], + "WeedleFrontpic": [ + 86, + 28651 + ], + "WeedlePokedexEntry": [ + 96, + 23476 + ], + "WeepinbellBackpic": [ + 86, + 26984 + ], + "WeepinbellFrontpic": [ + 80, + 19242 + ], + "WeepinbellPokedexEntry": [ + 110, + 16893 + ], + "WeezingBackpic": [ + 86, + 22607 + ], + "WeezingFrontpic": [ + 74, + 26071 + ], + "WeezingPokedexEntry": [ + 110, + 21228 + ], + "WentToSleepText": [ + 32, + 20439 + ], + "WhirlpoolTrapText": [ + 32, + 20004 + ], + "WigglytuffBackpic": [ + 88, + 28609 + ], + "WigglytuffFrontpic": [ + 78, + 20479 + ], + "WigglytuffPokedexEntry": [ + 96, + 26345 + ], + "WildCelebiAppearedText": [ + 32, + 18323 + ], + "WildPokemonAppearedText": [ + 32, + 18246 + ], + "WindowText": [ + 108, + 17568 + ], + "WobbuffetBackpic": [ + 88, + 25172 + ], + "WobbuffetFrontpic": [ + 79, + 26901 + ], + "WobbuffetPokedexEntry": [ + 116, + 17373 + ], + "WokeUpText": [ + 32, + 19706 + ], + "WontDropAnymoreText": [ + 32, + 21135 + ], + "WontObeyText": [ + 32, + 20292 + ], + "WontRiseAnymoreText": [ + 32, + 21106 + ], + "WooperBackpic": [ + 89, + 19239 + ], + "WooperFrontpic": [ + 85, + 30790 + ], + "WooperPokedexEntry": [ + 116, + 16504 + ], + "WrappedByText": [ + 32, + 20036 + ], + "WriteTileFromAnimBuffer": [ + 63, + 18070 + ], + "WrongNumber.script": [ + 36, + 16952 + ], + "XatuBackpic": [ + 87, + 20571 + ], + "XatuFrontpic": [ + 79, + 24508 + ], + "XatuPokedexEntry": [ + 115, + 21737 + ], + "YanmaBackpic": [ + 82, + 26224 + ], + "YanmaFrontpic": [ + 78, + 21149 + ], + "YanmaPokedexEntry": [ + 116, + 16384 + ], + "ZapdosBackpic": [ + 87, + 27161 + ], + "ZapdosFrontpic": [ + 73, + 27004 + ], + "ZapdosPokedexEntry": [ + 115, + 18071 + ], + "ZubatBackpic": [ + 83, + 27776 + ], + "ZubatFrontpic": [ + 81, + 28936 + ], + "ZubatPokedexEntry": [ + 96, + 26461 + ], + "_ActorNameText": [ + 112, + 19834 + ], + "_AlreadyASaveFileText": [ + 113, + 17827 + ], + "_AlreadySetUpText": [ + 111, + 17734 + ], + "_AlreadySurfingText": [ + 112, + 18004 + ], + "_AlreadyUsingStrengthText": [ + 112, + 18257 + ], + "_AnEggCantHoldAnItemText": [ + 112, + 19327 + ], + "_AnimationTypeText": [ + 112, + 20412 + ], + "_AnotherSaveFileText": [ + 113, + 17881 + ], + "_AreWeGeniusesText": [ + 111, + 23908 + ], + "_AreYouABoyOrAreYouAGirlText": [ + 112, + 19619 + ], + "_AskCutText": [ + 112, + 18909 + ], + "_AskDeleteMoveText": [ + 113, + 24282 + ], + "_AskFloorElevatorText": [ + 111, + 21180 + ], + "_AskForgetMoveText": [ + 113, + 22217 + ], + "_AskGiveNicknameText": [ + 113, + 23423 + ], + "_AskHeadbuttText": [ + 112, + 18620 + ], + "_AskItemMoveText": [ + 112, + 19555 + ], + "_AskQuantityThrowAwayText": [ + 112, + 19387 + ], + "_AskRockSmashText": [ + 112, + 18724 + ], + "_AskStrengthText": [ + 112, + 18336 + ], + "_AskSurfText": [ + 112, + 18028 + ], + "_AskThrowAwayText": [ + 112, + 19365 + ], + "_AskWaterfallText": [ + 112, + 18111 + ], + "_AskWhirlpoolText": [ + 112, + 18532 + ], + "_BGEventText": [ + 113, + 18172 + ], + "_BTExcuseMeText": [ + 113, + 22839 + ], + "_BackAlreadyText": [ + 111, + 24114 + ], + "_BadgeRequiredText": [ + 112, + 17831 + ], + "_BallAlmostHadItText": [ + 113, + 23267 + ], + "_BallAppearedCaughtText": [ + 113, + 23235 + ], + "_BallBlockedText": [ + 113, + 24016 + ], + "_BallBoxFullText": [ + 113, + 24122 + ], + "_BallBrokeFreeText": [ + 113, + 23206 + ], + "_BallDodgedText": [ + 113, + 23130 + ], + "_BallDontBeAThiefText": [ + 113, + 24047 + ], + "_BallMissedText": [ + 113, + 23184 + ], + "_BallSentToPCText": [ + 113, + 23352 + ], + "_BallSoCloseText": [ + 113, + 23290 + ], + "_BargainShopComeAgainText": [ + 113, + 20038 + ], + "_BargainShopFinalPriceText": [ + 113, + 19888 + ], + "_BargainShopIntroText": [ + 113, + 19783 + ], + "_BargainShopNoFundsText": [ + 113, + 20008 + ], + "_BargainShopPackFullText": [ + 113, + 19926 + ], + "_BargainShopSoldOutText": [ + 113, + 19959 + ], + "_BargainShopThanksText": [ + 113, + 19917 + ], + "_BattleDugText": [ + 112, + 19820 + ], + "_BattleFlewText": [ + 112, + 19804 + ], + "_BattleGlowingText": [ + 112, + 19790 + ], + "_BattleLoweredHeadText": [ + 112, + 19770 + ], + "_BattleMadeWhirlwindText": [ + 112, + 19730 + ], + "_BattleMonNickCommaText": [ + 112, + 17181 + ], + "_BattleMonNicknameText": [ + 112, + 17175 + ], + "_BattleStatFellText": [ + 112, + 19718 + ], + "_BattleStatSharplyFellText": [ + 112, + 19701 + ], + "_BattleStatWentUpText": [ + 112, + 19680 + ], + "_BattleStatWentWayUpText": [ + 112, + 19664 + ], + "_BattleTookSunlightText": [ + 112, + 19750 + ], + "_BattleTowerReturnWhenReadyText": [ + 113, + 22882 + ], + "_BenFernText1": [ + 111, + 19176 + ], + "_BenFernText2A": [ + 111, + 19190 + ], + "_BenFernText2B": [ + 111, + 19209 + ], + "_BenFernText3A": [ + 111, + 19227 + ], + "_BenFernText3B": [ + 111, + 19241 + ], + "_BenIntroText1": [ + 111, + 19097 + ], + "_BenIntroText2": [ + 111, + 19115 + ], + "_BenIntroText3": [ + 111, + 19126 + ], + "_BidsFarewellToMonText": [ + 111, + 18179 + ], + "_BlindingFlashText": [ + 112, + 17929 + ], + "_BlueCardBalanceText": [ + 113, + 23646 + ], + "_BoostedExpPointsText": [ + 112, + 17065 + ], + "_BootedHMText": [ + 112, + 17284 + ], + "_BootedTMText": [ + 112, + 17267 + ], + "_BouldersMayMoveText": [ + 112, + 18420 + ], + "_BouldersMoveText": [ + 112, + 18392 + ], + "_BreedAppearsToCareForText": [ + 112, + 20109 + ], + "_BreedAskNicknameText": [ + 112, + 19928 + ], + "_BreedBrimmingWithEnergyText": [ + 112, + 20052 + ], + "_BreedClearboxText": [ + 112, + 19896 + ], + "_BreedEggHatchText": [ + 112, + 19898 + ], + "_BreedFriendlyText": [ + 112, + 20140 + ], + "_BreedNoInterestText": [ + 112, + 20079 + ], + "_BreedShowsInterestText": [ + 112, + 20166 + ], + "_BreedingIsNotPossibleText": [ + 111, + 20669 + ], + "_BuenaAskWhichPrizeText": [ + 113, + 22687 + ], + "_BuenaComeAgainText": [ + 113, + 22810 + ], + "_BuenaHereYouGoText": [ + 113, + 22737 + ], + "_BuenaIsThatRightText": [ + 113, + 22716 + ], + "_BuenaNoRoomText": [ + 113, + 22783 + ], + "_BuenaNotEnoughPointsText": [ + 113, + 22752 + ], + "_BuenaOffTheAirText": [ + 111, + 20377 + ], + "_BuenaRadioMidnightText1": [ + 111, + 20203 + ], + "_BuenaRadioMidnightText10": [ + 111, + 20374 + ], + "_BuenaRadioMidnightText2": [ + 111, + 20219 + ], + "_BuenaRadioMidnightText3": [ + 111, + 20237 + ], + "_BuenaRadioMidnightText4": [ + 111, + 20258 + ], + "_BuenaRadioMidnightText5": [ + 111, + 20278 + ], + "_BuenaRadioMidnightText6": [ + 111, + 20299 + ], + "_BuenaRadioMidnightText7": [ + 111, + 20318 + ], + "_BuenaRadioMidnightText8": [ + 111, + 20339 + ], + "_BuenaRadioMidnightText9": [ + 111, + 20358 + ], + "_BuenaRadioText1": [ + 111, + 20082 + ], + "_BuenaRadioText2": [ + 111, + 20103 + ], + "_BuenaRadioText3": [ + 111, + 20122 + ], + "_BuenaRadioText4": [ + 111, + 20142 + ], + "_BuenaRadioText5": [ + 111, + 20151 + ], + "_BuenaRadioText6": [ + 111, + 20169 + ], + "_BuenaRadioText7": [ + 111, + 20188 + ], + "_BugCatchingContestIsOverText": [ + 111, + 21223 + ], + "_BugCatchingContestTimeUpText": [ + 111, + 21194 + ], + "_BurnWasHealedText": [ + 111, + 16618 + ], + "_ButNoSpaceText": [ + 111, + 21297 + ], + "_CGB_GSIntro.ShellderLaprasBGPalette": [ + 2, + 20814 + ], + "_CGB_GSIntro.ShellderLaprasOBPals": [ + 2, + 20822 + ], + "_CGB_PackPals.ChrisPackPals": [ + 2, + 21561 + ], + "_CGB_PackPals.KrisPackPals": [ + 2, + 21609 + ], + "_CameToItsSensesText": [ + 111, + 16750 + ], + "_CanCutText": [ + 112, + 18949 + ], + "_CantAcceptEggText": [ + 111, + 23736 + ], + "_CantCarryItemText": [ + 112, + 18988 + ], + "_CantGetOffBikeText": [ + 112, + 18842 + ], + "_CantRegisterText": [ + 112, + 19525 + ], + "_CantSurfText": [ + 112, + 17983 + ], + "_CantUseDigText": [ + 112, + 18181 + ], + "_CantUseItemText": [ + 112, + 17864 + ], + "_CantUseTeleportText": [ + 112, + 18235 + ], + "_CardFlipChooseACardText": [ + 113, + 22462 + ], + "_CardFlipDarnText": [ + 113, + 22554 + ], + "_CardFlipNotEnoughCoinsText": [ + 113, + 22443 + ], + "_CardFlipPlaceYourBetText": [ + 113, + 22478 + ], + "_CardFlipPlayAgainText": [ + 113, + 22495 + ], + "_CardFlipPlayWithThreeCoinsText": [ + 113, + 22419 + ], + "_CardFlipShuffledText": [ + 113, + 22516 + ], + "_CardFlipYeahText": [ + 113, + 22547 + ], + "_CardFolderOpenText": [ + 111, + 17032 + ], + "_CardWasListedText": [ + 111, + 20957 + ], + "_CaughtAskNicknameText": [ + 112, + 21244 + ], + "_ChangeBoxSaveText": [ + 113, + 17962 + ], + "_ChangeWhichNumberText": [ + 111, + 20574 + ], + "_CheckCDMAAdapterText": [ + 111, + 17340 + ], + "_CheckCellPhoneAdapterText": [ + 111, + 17313 + ], + "_CheckDDIPHSAdapterText": [ + 111, + 17388 + ], + "_CheckDOCOMOPHSAdapterText": [ + 111, + 17361 + ], + "_CheckMobileAdapterText": [ + 111, + 17412 + ], + "_ClearAllSaveDataText": [ + 113, + 22090 + ], + "_ClearTimeLimitText": [ + 111, + 21042 + ], + "_ClockHasResetText": [ + 113, + 16744 + ], + "_ClockIsThisOKText": [ + 113, + 16731 + ], + "_ClockSetWithControlPadText": [ + 113, + 16668 + ], + "_ClockTimeMayBeWrongText": [ + 113, + 16614 + ], + "_ClosingLinkText": [ + 111, + 21027 + ], + "_CoinCaseCountText": [ + 113, + 23675 + ], + "_ComeAgainText": [ + 111, + 24297 + ], + "_ComeBackLaterText": [ + 111, + 23883 + ], + "_ComeBackText": [ + 112, + 17254 + ], + "_CompatibilityShouldTheyBreedText": [ + 111, + 20696 + ], + "_ConfirmPasscodeText": [ + 111, + 16812 + ], + "_CongratulationsYourPokemonText": [ + 113, + 19346 + ], + "_ContainedMoveText": [ + 112, + 17302 + ], + "_ContestAlreadyCaughtText": [ + 112, + 20701 + ], + "_ContestAskSwitchText": [ + 112, + 20687 + ], + "_ContestCaughtMonText": [ + 112, + 20672 + ], + "_ContestJudging_FirstPlaceScoreText": [ + 112, + 20799 + ], + "_ContestJudging_FirstPlaceText": [ + 112, + 20730 + ], + "_ContestJudging_SecondPlaceScoreText": [ + 112, + 20886 + ], + "_ContestJudging_SecondPlaceText": [ + 112, + 20838 + ], + "_ContestJudging_ThirdPlaceScoreText": [ + 112, + 20964 + ], + "_ContestJudging_ThirdPlaceText": [ + 112, + 20917 + ], + "_CoordinatesEventText": [ + 113, + 18182 + ], + "_CorruptedEventText": [ + 113, + 18140 + ], + "_CuredOfPoisonText": [ + 111, + 16571 + ], + "_CutNothingText": [ + 112, + 17900 + ], + "_DSTIsThatOKText": [ + 113, + 24542 + ], + "_DayCareLadyIntroEggText": [ + 111, + 23483 + ], + "_DayCareLadyIntroText": [ + 111, + 23429 + ], + "_DayCareManIntroEggText": [ + 111, + 23256 + ], + "_DayCareManIntroText": [ + 111, + 23209 + ], + "_DaycareDummyText": [ + 111, + 23207 + ], + "_DeleteSavedLoginPasswordText": [ + 113, + 20886 + ], + "_DeletedTheLoginPasswordText": [ + 113, + 20921 + ], + "_DeleterAskWhichMonText": [ + 113, + 24529 + ], + "_DeleterAskWhichMoveText": [ + 113, + 24400 + ], + "_DeleterEggText": [ + 113, + 24343 + ], + "_DeleterForgotMoveText": [ + 113, + 24309 + ], + "_DeleterIntroText": [ + 113, + 24436 + ], + "_DeleterNoComeAgainText": [ + 113, + 24374 + ], + "_DidNotLearnMoveText": [ + 113, + 22191 + ], + "_DoItMonText": [ + 112, + 17126 + ], + "_DoesntConcernABoyText": [ + 111, + 20844 + ], + "_EggDoesNotQualifyText": [ + 113, + 22947 + ], + "_EggPhotoText": [ + 112, + 16417 + ], + "_EmptyMailboxText": [ + 112, + 20195 + ], + "_EndText": [ + 111, + 20813 + ], + "_EndUsedMove1Text": [ + 112, + 19873 + ], + "_EndUsedMove2Text": [ + 112, + 19876 + ], + "_EndUsedMove3Text": [ + 112, + 19879 + ], + "_EndUsedMove4Text": [ + 112, + 19882 + ], + "_EndUsedMove5Text": [ + 112, + 19885 + ], + "_EnemyUsedOnText": [ + 111, + 20399 + ], + "_EnemyWithdrewText": [ + 111, + 20380 + ], + "_EnterNewPasscodeText": [ + 111, + 16775 + ], + "_EnterPasscodeText": [ + 111, + 16977 + ], + "_EnterTheAmountText": [ + 111, + 17502 + ], + "_EnterTheIDNoText": [ + 111, + 17484 + ], + "_EvolvedIntoText": [ + 113, + 19375 + ], + "_EvolvingText": [ + 113, + 19427 + ], + "_ExcuseMeYoureNotReadyText": [ + 113, + 22852 + ], + "_ExpPointsText": [ + 112, + 17097 + ], + "_FernIntroText1": [ + 111, + 19144 + ], + "_FernIntroText2": [ + 111, + 19160 + ], + "_FluteWakeUpText": [ + 113, + 23592 + ], + "_ForABoyText": [ + 111, + 20819 + ], + "_ForAGirlText": [ + 111, + 20831 + ], + "_ForYourMonSendsText": [ + 111, + 18233 + ], + "_ForYourMonWillTradeText": [ + 111, + 18292 + ], + "_FoundAnEggText": [ + 111, + 24320 + ], + "_FoundItemText": [ + 112, + 18972 + ], + "_FourZerosInvalidText": [ + 111, + 16958 + ], + "_FruitBearingTreeText": [ + 111, + 16384 + ], + "_FruitPackIsFullText": [ + 111, + 16446 + ], + "_GearEllipseText": [ + 113, + 22564 + ], + "_GearOutOfServiceText": [ + 113, + 22567 + ], + "_GearTodayText": [ + 113, + 22561 + ], + "_GetFrontpic": [ + 20, + 20645 + ], + "_GoForItMonText": [ + 112, + 17136 + ], + "_GoMonText": [ + 112, + 17119 + ], + "_GoodComeBackText": [ + 112, + 17234 + ], + "_GotBackMonText": [ + 111, + 24095 + ], + "_GotOffBikeText": [ + 112, + 18887 + ], + "_GotOnBikeText": [ + 112, + 18866 + ], + "_GrewToLevelText": [ + 111, + 16719 + ], + "_HallOfFamePC.HOFMaster": [ + 33, + 26364 + ], + "_HallOfFamePC.TimeFamer": [ + 33, + 26380 + ], + "_HaveNoRoomText": [ + 111, + 24226 + ], + "_HeadbuttNothingText": [ + 112, + 18604 + ], + "_HealthReturnedText": [ + 111, + 16675 + ], + "_HerbShopLadyIntroText": [ + 113, + 19496 + ], + "_HerbalLadyComeAgainText": [ + 113, + 19754 + ], + "_HerbalLadyFinalPriceText": [ + 113, + 19630 + ], + "_HerbalLadyHowManyText": [ + 113, + 19619 + ], + "_HerbalLadyNoMoneyText": [ + 113, + 19720 + ], + "_HerbalLadyPackFullText": [ + 113, + 19690 + ], + "_HerbalLadyThanksText": [ + 113, + 19662 + ], + "_HeyItsFruitText": [ + 111, + 16412 + ], + "_HoldStillText": [ + 111, + 24647 + ], + "_HowManyShouldIMakeText": [ + 111, + 16521 + ], + "_HugeWaterfallText": [ + 112, + 18083 + ], + "_IllKeepItThanksText": [ + 111, + 24529 + ], + "_IllRaiseYourMonText": [ + 111, + 23856 + ], + "_IncorrectPasscodeText": [ + 111, + 17010 + ], + "_IsThisOKText": [ + 111, + 17471 + ], + "_ItemBelongsToSomeoneElseText": [ + 113, + 23959 + ], + "_ItemCantGetOnText": [ + 113, + 24093 + ], + "_ItemCantHeldText": [ + 112, + 23561 + ], + "_ItemCantUseOnEggText": [ + 113, + 23888 + ], + "_ItemCantUseOnMonText": [ + 113, + 23468 + ], + "_ItemGotOffText": [ + 113, + 24208 + ], + "_ItemGotOnText": [ + 113, + 24187 + ], + "_ItemLooksBitterText": [ + 113, + 23870 + ], + "_ItemOakWarningText": [ + 113, + 23918 + ], + "_ItemStatRoseText": [ + 113, + 23450 + ], + "_ItemStorageFullText": [ + 112, + 23466 + ], + "_ItemUsedText": [ + 113, + 24168 + ], + "_ItemWontHaveEffectText": [ + 113, + 23990 + ], + "_ItemfinderItemNearbyText": [ + 112, + 19063 + ], + "_ItemfinderNopeText": [ + 112, + 19113 + ], + "_ItemsDiscardedText": [ + 112, + 23242 + ], + "_ItemsOakWarningText": [ + 112, + 23299 + ], + "_ItemsThrowAwayText": [ + 112, + 23213 + ], + "_ItemsTooImportantText": [ + 112, + 23263 + ], + "_ItemsTossOutHowManyText": [ + 112, + 23184 + ], + "_ItsGoingToHatchText": [ + 111, + 20764 + ], + "_JustSawSomeRareMonText": [ + 111, + 21323 + ], + "_KarpGuruRecordText": [ + 112, + 21050 + ], + "_KnowsMoveText": [ + 113, + 24232 + ], + "_LC_DragText1": [ + 111, + 19473 + ], + "_LC_DragText2": [ + 111, + 19493 + ], + "_LC_Text1": [ + 111, + 19257 + ], + "_LC_Text10": [ + 111, + 19433 + ], + "_LC_Text11": [ + 111, + 19454 + ], + "_LC_Text2": [ + 111, + 19277 + ], + "_LC_Text3": [ + 111, + 19296 + ], + "_LC_Text4": [ + 111, + 19315 + ], + "_LC_Text5": [ + 111, + 19334 + ], + "_LC_Text6": [ + 111, + 19354 + ], + "_LC_Text7": [ + 111, + 19375 + ], + "_LC_Text8": [ + 111, + 19394 + ], + "_LC_Text9": [ + 111, + 19414 + ], + "_LastHealthyMonText": [ + 111, + 23807 + ], + "_LearnedMoveText": [ + 113, + 22112 + ], + "_LeftWithDayCareLadyText": [ + 112, + 19955 + ], + "_LeftWithDayCareManText": [ + 112, + 20004 + ], + "_LinkAbnormalMonText": [ + 113, + 16870 + ], + "_LinkAskTradeForText": [ + 113, + 16914 + ], + "_LinkTerminatedText": [ + 111, + 21009 + ], + "_LinkTimeoutText": [ + 113, + 16771 + ], + "_LinkTradeCantBattleText": [ + 113, + 16817 + ], + "_LookAdorableDecoText": [ + 111, + 17879 + ], + "_LookClefairyPosterText": [ + 111, + 17809 + ], + "_LookGiantDecoText": [ + 111, + 17903 + ], + "_LookJigglypuffPosterText": [ + 111, + 17843 + ], + "_LookPikachuPosterText": [ + 111, + 17776 + ], + "_LookTownMapText": [ + 111, + 17757 + ], + "_LuckyNumberMatchPCText": [ + 112, + 21166 + ], + "_LuckyNumberMatchPartyText": [ + 112, + 21089 + ], + "_MagikarpGuruMeasureText": [ + 112, + 20995 + ], + "_MailAlreadyHoldingItemText": [ + 112, + 20305 + ], + "_MailAskSendToPCText": [ + 112, + 23686 + ], + "_MailClearedPutAwayText": [ + 112, + 20217 + ], + "_MailDetachedText": [ + 112, + 23623 + ], + "_MailEggText": [ + 112, + 20337 + ], + "_MailLoseMessageText": [ + 112, + 23586 + ], + "_MailMessageLostText": [ + 112, + 20268 + ], + "_MailMovedFromBoxText": [ + 112, + 20365 + ], + "_MailNoSpaceText": [ + 112, + 23650 + ], + "_MailPackFullText": [ + 112, + 20249 + ], + "_MailSentToPCText": [ + 112, + 23748 + ], + "_MailboxFullText": [ + 112, + 23721 + ], + "_MainMenuTimeUnknownText": [ + 113, + 20866 + ], + "_MartAskMoreText": [ + 113, + 20493 + ], + "_MartBoughtText": [ + 113, + 20526 + ], + "_MartCantBuyText": [ + 113, + 20439 + ], + "_MartComeAgainText": [ + 113, + 20473 + ], + "_MartFinalPriceText": [ + 113, + 19464 + ], + "_MartHowManyText": [ + 113, + 19453 + ], + "_MartNoMoneyText": [ + 113, + 20378 + ], + "_MartPackFullText": [ + 113, + 20407 + ], + "_MartSellHowManyText": [ + 113, + 20275 + ], + "_MartSellPriceText": [ + 113, + 20286 + ], + "_MartThanksText": [ + 113, + 20352 + ], + "_MartWelcomeText": [ + 113, + 20322 + ], + "_MayPassWhirlpoolText": [ + 112, + 18475 + ], + "_MayRegisterItemText": [ + 112, + 23795 + ], + "_MaySmashText": [ + 112, + 18694 + ], + "_MemoryGameDarnText": [ + 112, + 23141 + ], + "_MemoryGameYeahText": [ + 112, + 23131 + ], + "_MobileBattleLessThanOneMinuteLeftText": [ + 113, + 17600 + ], + "_MobileBattleMoreInfoText": [ + 113, + 17013 + ], + "_MobileBattleMustPickThreeMonText": [ + 113, + 16938 + ], + "_MobileBattleNoTimeLeftForLinkingText": [ + 113, + 17639 + ], + "_MobileBattleRemainingTimeText": [ + 113, + 17701 + ], + "_MobileBattleRulesText": [ + 113, + 17048 + ], + "_MobileCardFolderAskDeleteOldText": [ + 113, + 21805 + ], + "_MobileCardFolderAskDeleteText": [ + 113, + 21486 + ], + "_MobileCardFolderAskOpenOldText": [ + 113, + 21725 + ], + "_MobileCardFolderDeleteAreYouSureText": [ + 113, + 21652 + ], + "_MobileCardFolderDeletedText": [ + 113, + 21689 + ], + "_MobileCardFolderFinishRegisteringCardsText": [ + 113, + 21834 + ], + "_MobileCardFolderIntro1Text": [ + 113, + 21048 + ], + "_MobileCardFolderIntro2Text": [ + 113, + 21180 + ], + "_MobileCardFolderIntro3Text": [ + 113, + 21278 + ], + "_MobileCardFolderIntro4Text": [ + 113, + 21396 + ], + "_MobileForPartnersMonText": [ + 111, + 18333 + ], + "_MobileOnlyThreeMonMayEnterText": [ + 113, + 21020 + ], + "_MobilePickThreeMonForBattleText": [ + 113, + 20951 + ], + "_MobilePlayerWillTradeMonText": [ + 111, + 18311 + ], + "_MobilePlayersMonTrade2Text": [ + 111, + 18397 + ], + "_MobilePlayersMonTradeText": [ + 111, + 18352 + ], + "_MobileStadiumEntryText": [ + 113, + 20674 + ], + "_MobileStadiumSuccessText": [ + 113, + 20769 + ], + "_MobileTakeGoodCareOfMonText": [ + 111, + 18371 + ], + "_MobileTakeGoodCareOfText": [ + 111, + 18416 + ], + "_MobileTradeCameBackText": [ + 111, + 18442 + ], + "_MobileUseTheseThreeMonText": [ + 113, + 20980 + ], + "_MomBankWhatDoYouWantToDoText": [ + 111, + 22850 + ], + "_MomBoughtWithYourMoneyText": [ + 111, + 17998 + ], + "_MomFoundADollText": [ + 111, + 18067 + ], + "_MomFoundAnItemText": [ + 111, + 17962 + ], + "_MomHaventSavedThatMuchText": [ + 111, + 22970 + ], + "_MomHiHowAreYouText": [ + 111, + 17941 + ], + "_MomInsufficientFundsInWalletText": [ + 111, + 23025 + ], + "_MomIsThisAboutYourMoneyText": [ + 111, + 22746 + ], + "_MomItsInPCText": [ + 111, + 18035 + ], + "_MomItsInYourRoomText": [ + 111, + 18119 + ], + "_MomJustDoWhatYouCanText": [ + 111, + 23184 + ], + "_MomLeavingText1": [ + 111, + 22399 + ], + "_MomLeavingText2": [ + 111, + 22632 + ], + "_MomLeavingText3": [ + 111, + 22670 + ], + "_MomLostGearBookletText": [ + 113, + 24785 + ], + "_MomNotEnoughRoomInBankText": [ + 111, + 23051 + ], + "_MomNotEnoughRoomInWalletText": [ + 111, + 22999 + ], + "_MomSaveMoneyText": [ + 111, + 22937 + ], + "_MomStartSavingMoneyText": [ + 111, + 23077 + ], + "_MomStoreMoneyText": [ + 111, + 22875 + ], + "_MomStoredMoneyText": [ + 111, + 23131 + ], + "_MomTakeMoneyText": [ + 111, + 22906 + ], + "_MomTakenMoneyText": [ + 111, + 23166 + ], + "_MonNameBidsFarewellText": [ + 111, + 18201 + ], + "_MonNameSentToText": [ + 111, + 18177 + ], + "_MonNumberText": [ + 112, + 20445 + ], + "_MonWasSentToText": [ + 111, + 18153 + ], + "_MoveAskForgetText": [ + 113, + 22136 + ], + "_MoveBoulderText": [ + 112, + 18312 + ], + "_MoveCantForgetHMText": [ + 113, + 22386 + ], + "_MoveForgotText": [ + 113, + 22350 + ], + "_MoveKnowsOneText": [ + 113, + 24250 + ], + "_MoveMonWOMailSaveText": [ + 113, + 18015 + ], + "_MoveNameText": [ + 112, + 19868 + ], + "_MysteryGiftCanceledText": [ + 112, + 17462 + ], + "_MysteryGiftCommErrorText": [ + 112, + 17492 + ], + "_MysteryGiftFiveADayText": [ + 112, + 17575 + ], + "_MysteryGiftOneADayText": [ + 112, + 17606 + ], + "_MysteryGiftSentHomeText": [ + 112, + 17658 + ], + "_MysteryGiftSentText": [ + 112, + 17641 + ], + "_NPCTradeAfterText1": [ + 111, + 21748 + ], + "_NPCTradeAfterText2": [ + 111, + 22004 + ], + "_NPCTradeAfterText3": [ + 111, + 22261 + ], + "_NPCTradeAfterText4": [ + 111, + 22341 + ], + "_NPCTradeCableText": [ + 111, + 21511 + ], + "_NPCTradeCancelText1": [ + 111, + 21644 + ], + "_NPCTradeCancelText2": [ + 111, + 21861 + ], + "_NPCTradeCancelText3": [ + 111, + 22131 + ], + "_NPCTradeCompleteText1": [ + 111, + 21714 + ], + "_NPCTradeCompleteText2": [ + 111, + 21964 + ], + "_NPCTradeCompleteText3": [ + 111, + 22221 + ], + "_NPCTradeCompleteText4": [ + 111, + 22321 + ], + "_NPCTradeFanfareText": [ + 111, + 21573 + ], + "_NPCTradeIntroText1": [ + 111, + 21577 + ], + "_NPCTradeIntroText2": [ + 111, + 21778 + ], + "_NPCTradeIntroText3": [ + 111, + 22049 + ], + "_NPCTradeWrongText1": [ + 111, + 21674 + ], + "_NPCTradeWrongText2": [ + 111, + 21921 + ], + "_NPCTradeWrongText3": [ + 111, + 22166 + ], + "_NameCardLinkCancelledText": [ + 112, + 17779 + ], + "_NameCardLinkCommErrorText": [ + 112, + 17809 + ], + "_NameCardListedCardText": [ + 112, + 17713 + ], + "_NameCardNotRegisteredCardText": [ + 112, + 17749 + ], + "_NameCardReceivedCardText": [ + 112, + 17690 + ], + "_NameRaterBetterNameText": [ + 112, + 16589 + ], + "_NameRaterComeAgainText": [ + 112, + 16798 + ], + "_NameRaterEggText": [ + 112, + 16904 + ], + "_NameRaterFinishedText": [ + 112, + 16753 + ], + "_NameRaterHelloText": [ + 112, + 16451 + ], + "_NameRaterNamedText": [ + 112, + 17010 + ], + "_NameRaterPerfectNameText": [ + 112, + 16830 + ], + "_NameRaterSameNameText": [ + 112, + 16930 + ], + "_NameRaterWhatNameText": [ + 112, + 16706 + ], + "_NameRaterWhichMonText": [ + 112, + 16544 + ], + "_NeedAtLeastThreeMonText": [ + 113, + 22915 + ], + "_NewCardArrivedText": [ + 111, + 20890 + ], + "_NewDexDataText": [ + 113, + 23379 + ], + "_NewPokedexEntry": [ + 16, + 23167 + ], + "_NoCoinCaseText": [ + 111, + 21483 + ], + "_NoCoinsText": [ + 111, + 21463 + ], + "_NoCyclingText": [ + 113, + 24065 + ], + "_NoPhotoText": [ + 112, + 16384 + ], + "_NoPromptText": [ + 112, + 20408 + ], + "_NoRoomForEggText": [ + 111, + 24562 + ], + "_NoRoomTMHMText": [ + 112, + 17402 + ], + "_NotEnoughMoneyText": [ + 111, + 24252 + ], + "_NotYetText": [ + 111, + 24310 + ], + "_NothingHereText": [ + 111, + 16469 + ], + "_NothingToChooseText": [ + 111, + 17521 + ], + "_NothingToPutAwayText": [ + 111, + 17644 + ], + "_NothingToSellText": [ + 113, + 20242 + ], + "_OKComeBackText": [ + 112, + 17216 + ], + "_OPT_AddictivelyText": [ + 111, + 18766 + ], + "_OPT_AlmostPoisonouslyText": [ + 111, + 18686 + ], + "_OPT_AptlyNamedText": [ + 111, + 18607 + ], + "_OPT_BoldSortOfText": [ + 111, + 18909 + ], + "_OPT_CuteText": [ + 111, + 18880 + ], + "_OPT_EvolutionMustBeText": [ + 111, + 18805 + ], + "_OPT_ExcitingText": [ + 111, + 18973 + ], + "_OPT_FlippedOutText": [ + 111, + 18841 + ], + "_OPT_FriendlyText": [ + 111, + 19008 + ], + "_OPT_FrighteningText": [ + 111, + 18926 + ], + "_OPT_GroovyText": [ + 111, + 18985 + ], + "_OPT_GuardedText": [ + 111, + 19052 + ], + "_OPT_HeartMeltinglyText": [ + 111, + 18862 + ], + "_OPT_HotHotHotText": [ + 111, + 19020 + ], + "_OPT_InspiringText": [ + 111, + 18995 + ], + "_OPT_IntroText1": [ + 111, + 18458 + ], + "_OPT_IntroText2": [ + 111, + 18477 + ], + "_OPT_IntroText3": [ + 111, + 18490 + ], + "_OPT_LooksInWaterText": [ + 111, + 18785 + ], + "_OPT_LovelyText": [ + 111, + 19063 + ], + "_OPT_MaryText1": [ + 111, + 18550 + ], + "_OPT_MischievouslyText": [ + 111, + 18727 + ], + "_OPT_OakText1": [ + 111, + 18507 + ], + "_OPT_OakText2": [ + 111, + 18520 + ], + "_OPT_OakText3": [ + 111, + 18541 + ], + "_OPT_PleasantText": [ + 111, + 18897 + ], + "_OPT_PokemonChannelText": [ + 111, + 19083 + ], + "_OPT_PowerfulText": [ + 111, + 18961 + ], + "_OPT_ProvocativelyText": [ + 111, + 18825 + ], + "_OPT_SensuallyText": [ + 111, + 18707 + ], + "_OPT_SpeedyText": [ + 111, + 19073 + ], + "_OPT_StimulatingText": [ + 111, + 19037 + ], + "_OPT_SuaveDebonairText": [ + 111, + 18941 + ], + "_OPT_SweetAdorablyText": [ + 111, + 18565 + ], + "_OPT_TopicallyText": [ + 111, + 18746 + ], + "_OPT_UnbearablyText": [ + 111, + 18646 + ], + "_OPT_UndeniablyKindOfText": [ + 111, + 18625 + ], + "_OPT_WeirdText": [ + 111, + 18888 + ], + "_OPT_WigglySlicklyText": [ + 111, + 18586 + ], + "_OPT_WowImpressivelyText": [ + 111, + 18666 + ], + "_OTSendsText": [ + 111, + 18252 + ], + "_OakPCText1": [ + 112, + 21781 + ], + "_OakPCText2": [ + 112, + 21811 + ], + "_OakPCText3": [ + 112, + 21843 + ], + "_OakPCText4": [ + 112, + 22954 + ], + "_OakRating01": [ + 112, + 21893 + ], + "_OakRating02": [ + 112, + 21925 + ], + "_OakRating03": [ + 112, + 21973 + ], + "_OakRating04": [ + 112, + 22033 + ], + "_OakRating05": [ + 112, + 22095 + ], + "_OakRating06": [ + 112, + 22156 + ], + "_OakRating07": [ + 112, + 22218 + ], + "_OakRating08": [ + 112, + 22281 + ], + "_OakRating09": [ + 112, + 22331 + ], + "_OakRating10": [ + 112, + 22387 + ], + "_OakRating11": [ + 112, + 22436 + ], + "_OakRating12": [ + 112, + 22496 + ], + "_OakRating13": [ + 112, + 22546 + ], + "_OakRating14": [ + 112, + 22611 + ], + "_OakRating15": [ + 112, + 22667 + ], + "_OakRating16": [ + 112, + 22716 + ], + "_OakRating17": [ + 112, + 22775 + ], + "_OakRating18": [ + 112, + 22834 + ], + "_OakRating19": [ + 112, + 22891 + ], + "_OakText1": [ + 112, + 23861 + ], + "_OakText2": [ + 112, + 23972 + ], + "_OakText3": [ + 112, + 24034 + ], + "_OakText4": [ + 112, + 24037 + ], + "_OakText5": [ + 112, + 24145 + ], + "_OakText6": [ + 113, + 16384 + ], + "_OakText7": [ + 113, + 16422 + ], + "_OakThisIsntTheTimeText": [ + 112, + 19438 + ], + "_OakTimeHoursQuestionMarkText": [ + 111, + 17157 + ], + "_OakTimeHowManyMinutesText": [ + 111, + 17160 + ], + "_OakTimeIsItText": [ + 111, + 17274 + ], + "_OakTimeMinutesQuestionMarkText": [ + 111, + 17187 + ], + "_OakTimeOversleptText": [ + 111, + 17190 + ], + "_OakTimeSoDarkText": [ + 111, + 17231 + ], + "_OakTimeWhatDayIsItText": [ + 111, + 17257 + ], + "_OakTimeWhatHoursText": [ + 111, + 17149 + ], + "_OakTimeWhatTimeIsItText": [ + 111, + 17131 + ], + "_OakTimeWhoaMinutesText": [ + 111, + 17179 + ], + "_OakTimeWokeUpText": [ + 111, + 17052 + ], + "_OakTimeYikesText": [ + 111, + 17206 + ], + "_ObjectEventText": [ + 113, + 18158 + ], + "_ObtainedFruitText": [ + 111, + 16429 + ], + "_ObtainedTheVoltorbBadgeText": [ + 111, + 21152 + ], + "_OhFineThenText": [ + 111, + 24281 + ], + "_OnlyOneMonText": [ + 111, + 23703 + ], + "_OnlyThreeMonMayBeEnteredText": [ + 113, + 22979 + ], + "_PCCantDepositLastMonText": [ + 112, + 20608 + ], + "_PCCantTakeText": [ + 112, + 20642 + ], + "_PCGottaHavePokemonText": [ + 112, + 20486 + ], + "_PCMonHoldingMailText": [ + 112, + 20523 + ], + "_PCNoSingleMonText": [ + 112, + 20578 + ], + "_PCWhatText": [ + 112, + 20516 + ], + "_PPIsMaxedOutText": [ + 113, + 23750 + ], + "_PPRestoredText": [ + 113, + 23793 + ], + "_PPsIncreasedText": [ + 113, + 23773 + ], + "_PackEmptyText": [ + 112, + 19587 + ], + "_PackNoItemText": [ + 112, + 19354 + ], + "_PasscodeSetText": [ + 111, + 16875 + ], + "_PasscodesNotSameText": [ + 111, + 16847 + ], + "_PasswordAskEnterText": [ + 113, + 22062 + ], + "_PasswordAskResetClockText": [ + 113, + 22044 + ], + "_PasswordAskResetText": [ + 113, + 21979 + ], + "_PasswordWrongText": [ + 113, + 22027 + ], + "_PerfectHeresYourMonText": [ + 111, + 24068 + ], + "_PharmacyComeAgainText": [ + 113, + 20214 + ], + "_PharmacyFinalPriceText": [ + 113, + 20105 + ], + "_PharmacyHowManyText": [ + 113, + 20094 + ], + "_PharmacyIntroText": [ + 113, + 20063 + ], + "_PharmacyNoMoneyText": [ + 113, + 20184 + ], + "_PharmacyPackFullText": [ + 113, + 20153 + ], + "_PharmacyThanksText": [ + 113, + 20139 + ], + "_PhoneClickText": [ + 113, + 21888 + ], + "_PhoneEllipseText": [ + 113, + 21896 + ], + "_PhoneJustTalkToThemText": [ + 113, + 21932 + ], + "_PhoneOutOfAreaText": [ + 113, + 21899 + ], + "_PhoneThankYouText": [ + 113, + 21962 + ], + "_PhoneWrongNumberText": [ + 113, + 21861 + ], + "_PickErrorPacketText": [ + 111, + 21094 + ], + "_PickThreeMonForMobileBattleText": [ + 113, + 17672 + ], + "_PlayedFluteText": [ + 113, + 23545 + ], + "_PlayerFoundItemText": [ + 111, + 21281 + ], + "_PlayersPCAskWhatDoText": [ + 112, + 21352 + ], + "_PlayersPCDepositItemsText": [ + 112, + 21521 + ], + "_PlayersPCHowManyDepositText": [ + 112, + 21487 + ], + "_PlayersPCHowManyWithdrawText": [ + 112, + 21377 + ], + "_PlayersPCNoItemsText": [ + 112, + 21471 + ], + "_PlayersPCNoRoomDepositText": [ + 112, + 21549 + ], + "_PlayersPCNoRoomWithdrawText": [ + 112, + 21439 + ], + "_PlayersPCTurnOnText": [ + 112, + 21331 + ], + "_PlayersPCWithdrewItemsText": [ + 112, + 21412 + ], + "_PleaseTryAgainTomorrowText": [ + 113, + 17498 + ], + "_PnP_BoldText": [ + 111, + 19668 + ], + "_PnP_CoolText": [ + 111, + 19778 + ], + "_PnP_CuteText": [ + 111, + 19584 + ], + "_PnP_GreatText": [ + 111, + 19738 + ], + "_PnP_HappyText": [ + 111, + 19614 + ], + "_PnP_InspiringText": [ + 111, + 19796 + ], + "_PnP_LazyText": [ + 111, + 19595 + ], + "_PnP_MyTypeText": [ + 111, + 19759 + ], + "_PnP_NoisyText": [ + 111, + 19633 + ], + "_PnP_OddText": [ + 111, + 19851 + ], + "_PnP_PickyText": [ + 111, + 19688 + ], + "_PnP_PrecociousText": [ + 111, + 19651 + ], + "_PnP_RightForMeText": [ + 111, + 19832 + ], + "_PnP_SoSoText": [ + 111, + 19721 + ], + "_PnP_SortOfOKText": [ + 111, + 19704 + ], + "_PnP_Text1": [ + 111, + 19514 + ], + "_PnP_Text2": [ + 111, + 19535 + ], + "_PnP_Text3": [ + 111, + 19555 + ], + "_PnP_Text4": [ + 111, + 19570 + ], + "_PnP_Text5": [ + 111, + 19872 + ], + "_PnP_WeirdText": [ + 111, + 19812 + ], + "_PocketIsFullText": [ + 113, + 18251 + ], + "_PoisonFaintText": [ + 112, + 19148 + ], + "_PoisonWhiteoutText": [ + 112, + 19162 + ], + "_PokecenterBillsPCText": [ + 112, + 21620 + ], + "_PokecenterOaksPCText": [ + 112, + 21714 + ], + "_PokecenterPCCantUseText": [ + 112, + 21288 + ], + "_PokecenterPCOaksClosedText": [ + 112, + 21765 + ], + "_PokecenterPCTurnOnText": [ + 112, + 21581 + ], + "_PokecenterPCWhoseText": [ + 112, + 21602 + ], + "_PokecenterPlayersPCText": [ + 112, + 21668 + ], + "_PokedexShowText": [ + 111, + 19089 + ], + "_PokegearAskDeleteText": [ + 113, + 22653 + ], + "_PokegearAskWhoCallText": [ + 113, + 22599 + ], + "_PokegearPressButtonText": [ + 113, + 22626 + ], + "_PokemonAskSwapItemText": [ + 112, + 23516 + ], + "_PokemonHoldItemText": [ + 112, + 23383 + ], + "_PokemonNotEnoughHPText": [ + 112, + 23779 + ], + "_PokemonNotHoldingText": [ + 112, + 23438 + ], + "_PokemonRemoveMailText": [ + 112, + 23407 + ], + "_PokemonSwapItemText": [ + 112, + 23340 + ], + "_PokemonTookItemText": [ + 112, + 23492 + ], + "_PrepMonFrontpic": [ + 0, + 14219 + ], + "_PrestoAllDoneText": [ + 111, + 24682 + ], + "_PutAwayAndSetUpText": [ + 111, + 17692 + ], + "_PutAwayTheDecoText": [ + 111, + 17623 + ], + "_PutCardInCardFolderText": [ + 111, + 20922 + ], + "_PutItemInPocketText": [ + 113, + 18220 + ], + "_RaiseThePPOfWhichMoveText": [ + 113, + 23690 + ], + "_ReceiveItemText": [ + 111, + 21438 + ], + "_ReceivedEggText": [ + 111, + 24485 + ], + "_ReceivedItemText": [ + 113, + 18201 + ], + "_ReceivedTMHMText": [ + 112, + 17441 + ], + "_RecoveredSomeHPText": [ + 111, + 16546 + ], + "_RegisteredItemText": [ + 112, + 19502 + ], + "_RemainingTimeText": [ + 111, + 20521 + ], + "_RemoveMailText": [ + 111, + 23770 + ], + "_RepelUsedEarlierIsStillInEffectText": [ + 113, + 23501 + ], + "_RepelWoreOffText": [ + 111, + 21256 + ], + "_RestoreThePPOfWhichMoveText": [ + 113, + 23719 + ], + "_RetrieveMysteryGiftText": [ + 112, + 17514 + ], + "_RevitalizedText": [ + 111, + 16698 + ], + "_RidOfParalysisText": [ + 111, + 16594 + ], + "_RocketRadioText1": [ + 111, + 19880 + ], + "_RocketRadioText10": [ + 111, + 20060 + ], + "_RocketRadioText2": [ + 111, + 19898 + ], + "_RocketRadioText3": [ + 111, + 19913 + ], + "_RocketRadioText4": [ + 111, + 19933 + ], + "_RocketRadioText5": [ + 111, + 19954 + ], + "_RocketRadioText6": [ + 111, + 19973 + ], + "_RocketRadioText7": [ + 111, + 19991 + ], + "_RocketRadioText8": [ + 111, + 20014 + ], + "_RocketRadioText9": [ + 111, + 20036 + ], + "_RodBiteText": [ + 112, + 18776 + ], + "_RodNothingText": [ + 112, + 18789 + ], + "_SaveFileCorruptedText": [ + 113, + 17933 + ], + "_SavedTheGameText": [ + 113, + 17808 + ], + "_SavingDontTurnOffThePowerText": [ + 113, + 17773 + ], + "_SavingRecordText": [ + 111, + 21406 + ], + "_SeerCantTellAThingText": [ + 113, + 18327 + ], + "_SeerDoNothingText": [ + 113, + 18773 + ], + "_SeerEggText": [ + 113, + 18717 + ], + "_SeerImpressedText": [ + 113, + 19173 + ], + "_SeerMightyText": [ + 113, + 19035 + ], + "_SeerMoreCareText": [ + 113, + 18810 + ], + "_SeerMoreConfidentText": [ + 113, + 18886 + ], + "_SeerMuchStrengthText": [ + 113, + 18977 + ], + "_SeerNameLocationText": [ + 113, + 18388 + ], + "_SeerNoLocationText": [ + 113, + 18559 + ], + "_SeerSeeAllText": [ + 113, + 18271 + ], + "_SeerTimeLevelText": [ + 113, + 18426 + ], + "_SeerTradeText": [ + 113, + 18487 + ], + "_SentTrophyHomeText": [ + 113, + 23811 + ], + "_SetUpTheDecoText": [ + 111, + 17673 + ], + "_SlotsBetHowManyCoinsText": [ + 113, + 20553 + ], + "_SlotsDarnText": [ + 113, + 20667 + ], + "_SlotsLinedUpText": [ + 113, + 20639 + ], + "_SlotsNotEnoughCoinsText": [ + 113, + 20582 + ], + "_SlotsPlayAgainText": [ + 113, + 20626 + ], + "_SlotsRanOutOfCoinsText": [ + 113, + 20601 + ], + "_SlotsStartText": [ + 113, + 20574 + ], + "_SpaceSpaceColonText": [ + 113, + 21974 + ], + "_SquirtbottleNothingText": [ + 112, + 19259 + ], + "_StartMenuContestEndText": [ + 112, + 23148 + ], + "_StartText": [ + 111, + 20805 + ], + "_StartingLinkText": [ + 111, + 20993 + ], + "_StopLearningMoveText": [ + 113, + 22169 + ], + "_StoppedEvolvingText": [ + 113, + 19397 + ], + "_SweetScentNothingText": [ + 112, + 19226 + ], + "_TMHMNotCompatibleText": [ + 112, + 17346 + ], + "_TakeGoodCareOfEggText": [ + 111, + 24506 + ], + "_TakeGoodCareOfMonText": [ + 111, + 18207 + ], + "_TeleportReturnText": [ + 112, + 18202 + ], + "_TestEventText": [ + 111, + 20785 + ], + "_ThatCantBeUsedRightNowText": [ + 111, + 20423 + ], + "_ThatItemCantBePutInThePackText": [ + 111, + 20453 + ], + "_ThatsEnoughComeBackText": [ + 112, + 17188 + ], + "_TheBoxIsFullText": [ + 111, + 20872 + ], + "_TheItemWasPutInThePackText": [ + 111, + 20489 + ], + "_TheMonMustAllBeDifferentKindsText": [ + 113, + 23013 + ], + "_TheMonMustNotHoldTheSameItemsText": [ + 113, + 23059 + ], + "_ThePasswordIsText": [ + 111, + 17452 + ], + "_ThereIsNoEggText": [ + 111, + 20745 + ], + "_ThereIsNothingConnectedText": [ + 111, + 17284 + ], + "_ThrewAwayText": [ + 112, + 19416 + ], + "_TimeAskOkayText": [ + 113, + 24561 + ], + "_TimeLimitWasClearedText": [ + 111, + 21065 + ], + "_TimesetAskAdjustDSTText": [ + 113, + 24725 + ], + "_TimesetAskDSTText": [ + 113, + 24576 + ], + "_TimesetAskNotDSTText": [ + 113, + 24662 + ], + "_TimesetDSTText": [ + 113, + 24624 + ], + "_TimesetNotDSTText": [ + 113, + 24693 + ], + "_TradingMonForOTMonText": [ + 111, + 21126 + ], + "_TrainerRankingExplanationText": [ + 112, + 22989 + ], + "_TrainerRankingNoDataText": [ + 112, + 23074 + ], + "_TryAgainUsingSameSettingsText": [ + 113, + 17564 + ], + "_UnusedNothingHereText": [ + 112, + 18809 + ], + "_UseCutText": [ + 112, + 17885 + ], + "_UseDigText": [ + 112, + 18142 + ], + "_UseEscapeRopeText": [ + 112, + 18157 + ], + "_UseHeadbuttText": [ + 112, + 18583 + ], + "_UseRockSmashText": [ + 112, + 18672 + ], + "_UseSacredAshText": [ + 112, + 19301 + ], + "_UseStrengthText": [ + 112, + 18292 + ], + "_UseSweetScentText": [ + 112, + 19203 + ], + "_UseWaterfallText": [ + 112, + 18062 + ], + "_UseWhirlpoolText": [ + 112, + 18454 + ], + "_UsedInsteadText": [ + 112, + 19856 + ], + "_UsedMove1Text": [ + 112, + 19838 + ], + "_UsedMove2Text": [ + 112, + 19847 + ], + "_UsedSurfText": [ + 112, + 17967 + ], + "_WaitButtonText": [ + 113, + 23349 + ], + "_WantAQuickMobileBattleText": [ + 113, + 17372 + ], + "_WantToRushThroughAMobileBattleText": [ + 113, + 17433 + ], + "_WarpingText": [ + 111, + 20564 + ], + "_WasDefrostedText": [ + 111, + 16641 + ], + "_WasSentToBillsPCText": [ + 112, + 20459 + ], + "_WhatShouldIRaiseText": [ + 111, + 23673 + ], + "_WhichApricornText": [ + 111, + 16491 + ], + "_WhichMonPhotoText": [ + 111, + 24612 + ], + "_WhichSidePutAwayText": [ + 111, + 17586 + ], + "_WhichSidePutOnText": [ + 111, + 17548 + ], + "_WhitedOutText": [ + 112, + 19022 + ], + "_WillTradeText": [ + 111, + 18270 + ], + "_WillYouPlayWithMonText": [ + 111, + 20607 + ], + "_WindowAreaExceededErrorText": [ + 113, + 18067 + ], + "_WindowPoppingErrorText": [ + 113, + 18103 + ], + "_WokeUpText": [ + 111, + 16661 + ], + "_WouldYouLikeToMobileBattleText": [ + 113, + 17308 + ], + "_WouldYouLikeToSaveTheGameText": [ + 113, + 17739 + ], + "_YesPromptText": [ + 112, + 20403 + ], + "_YouCantTakeAnEggText": [ + 113, + 23106 + ], + "_YouCantUseItInABattleText": [ + 112, + 19589 + ], + "_YouDontHaveAMonText": [ + 112, + 19479 + ], + "_YouNeedTwoMonForBreedingText": [ + 111, + 20634 + ], + "_YourFoesWeakGetmMonText": [ + 112, + 17150 + ], + "_YourFriendIsNotReadyText": [ + 112, + 17550 + ], + "_YourMonHasGrownText": [ + 111, + 23958 + ], + "_YourMonsHPWasHealedText": [ + 111, + 20537 + ], + "wBaseUnusedBackpic": [ + 1, + 53834 + ], + "wBaseUnusedFrontpic": [ + 1, + 53832 + ], + "wDecompressEnemyFrontpic": [ + 6, + 55296 + ] + }, + "text": { + "labels": [ + "AlreadyAsleepText", + "AlreadyConfusedText", + "AlreadyParalyzedText", + "AlreadyPoisonedText", + "AttackMissed2Text", + "AttackMissedText", + "BadlyPoisonedText", + "BattleText_AnEGGCantBattle", + "BattleText_CantEscape", + "BattleText_CantEscape2", + "BattleText_EnemyFled", + "BattleText_EnemyIsAboutToUseWillPlayerChangeMon", + "BattleText_EnemyMonFainted", + "BattleText_EnemySentOut", + "BattleText_EnemyWasDefeated", + "BattleText_GotAwaySafely", + "BattleText_ItemHealedConfusion", + "BattleText_ItemsCantBeUsedHere", + "BattleText_LinkErrorBattleCanceled", + "BattleText_MonCantBeRecalled", + "BattleText_MonFainted", + "BattleText_MonHasNoMovesLeft", + "BattleText_MonIsAlreadyOut", + "BattleText_MonsLightScreenFell", + "BattleText_MonsReflectFaded", + "BattleText_NoTimeLeftToday", + "BattleText_PlayerPickedUpPayDayMoney", + "BattleText_RainContinuesToFall", + "BattleText_SafeguardFaded", + "BattleText_StringBuffer1GrewToLevel", + "BattleText_TargetRecoveredWithItem", + "BattleText_TargetWasHitByFutureSight", + "BattleText_TargetsEncoreEnded", + "BattleText_TheMoveIsDisabled", + "BattleText_TheRainStopped", + "BattleText_TheSandstormRages", + "BattleText_TheSandstormSubsided", + "BattleText_TheSunlightFaded", + "BattleText_TheSunlightIsStrong", + "BattleText_TheresNoEscapeFromTrainerBattle", + "BattleText_TheresNoPPLeftForThisMove", + "BattleText_TheresNoWillToBattle", + "BattleText_UseNextMon", + "BattleText_UserFledUsingAStringBuffer1", + "BattleText_UserHurtBySpikes", + "BattleText_UserRecoveredPPUsing", + "BattleText_UserWasReleasedFromStringBuffer1", + "BattleText_UsersHurtByStringBuffer1", + "BattleText_UsersStringBuffer1Activated", + "BattleText_WildFled", + "BattleText_WildMonIsAngry", + "BattleText_WildMonIsEating", + "BeatUpAttackText", + "BecameConfusedText", + "BeganToNapText", + "BellChimedText", + "BellyDrumText", + "BlewSpikesText", + "BlownAwayText", + "BracedItselfText", + "BugContestPrizeNoRoomText", + "ButItFailedText", + "CantEscapeNowText", + "ClampedByText", + "CoinVendor_Buy500CoinsText", + "CoinVendor_Buy50CoinsText", + "CoinVendor_CancelText", + "CoinVendor_CoinCaseFullText", + "CoinVendor_IntroText", + "CoinVendor_NoCoinCaseText", + "CoinVendor_NotEnoughMoneyText", + "CoinVendor_WelcomeText", + "CoinsScatteredText", + "ConfusedNoMoreText", + "ContestResults_ConsolationPrizeText", + "ContestResults_DidNotWinText", + "ContestResults_JoinUsNextTimeText", + "ContestResults_PartyFullText", + "ContestResults_PlayerWonAPrizeText", + "ContestResults_ReadyToJudgeText", + "ContestResults_ReturnPartyText", + "CopiedStatsText", + "CoveredByVeilText", + "CrashedText", + "CriticalHitText", + "DefrostedOpponentText", + "DestinyBondEffectText", + "DidntAffect1Text", + "DidntAffect2Text", + "DifficultBookshelfText", + "DisabledMoveText", + "DisabledNoMoreText", + "DoesntAffectText", + "DownpourText", + "DraggedOutText", + "DreamEatenText", + "EliminatedStatsText", + "EnduredText", + "EnemyHitTimesText", + "EvadedText", + "FastAsleepText", + "FellAsleepText", + "FellInLoveText", + "FireSpinTrapText", + "FledFromBattleText", + "FledInFearText", + "FlinchedText", + "ForesawAttackText", + "FrozenSolidText", + "FullyParalyzedText", + "GettingPumpedText", + "GotAnEncoreText", + "GotMoneyForWinningText", + "GymStatue_CityGymText", + "GymStatue_WinningTrainersText", + "HPIsFullText", + "HappinessText1", + "HappinessText2", + "HappinessText3", + "HasANightmareText", + "HasNoPPLeftText", + "HasSubstituteText", + "HomepageText", + "HookedPokemonAttackedText", + "HungOnText", + "HurtByBurnText", + "HurtByCurseText", + "HurtByPoisonText", + "HurtItselfText", + "IdentifiedText", + "IgnoredOrders2Text", + "IgnoredOrdersText", + "IgnoredSleepingText", + "InLoveWithText", + "IncenseBurnerText", + "InfatuationText", + "IsConfusedText", + "ItFailedText", + "LeechSeedSapsText", + "LightScreenEffectText", + "LoafingAroundText", + "LookTownMapText", + "LostAgainstText", + "MadeSubstituteText", + "MagazineBookshelfText", + "MagnitudeText", + "MartSignText", + "MerchandiseShelfText", + "MimicLearnedMoveText", + "MirrorMoveFailedText", + "MistText", + "MustRechargeText", + "NoPPLeftText", + "NotVeryEffectiveText", + "NothingHappenedText", + "NurseAskHealText", + "NurseDayText", + "NurseGoodbyeText", + "NurseMornText", + "NurseNiteText", + "NursePokerusText", + "NurseReturnPokemonText", + "NurseTakePokemonText", + "OneHitKOText", + "ParalyzedText", + "PerishCountText", + "PictureBookshelfText", + "PlayerHitTimesText", + "PokeComNurseDayText", + "PokeComNurseMornText", + "PokeComNurseNiteText", + "PokeComNursePokerusText", + "PokecenterSignText", + "PokemonFellFromTreeText", + "PresentFailedText", + "ProtectedByMistText", + "ProtectedByText", + "ProtectedItselfText", + "ProtectingItselfText", + "PutACurseText", + "RageBuildingText", + "ReceivedItemText", + "RecoilText", + "RecoveredUsingText", + "ReflectEffectText", + "RegainedHealthText", + "RegisteredNumber1Text", + "RegisteredNumber2Text", + "ReleasedByText", + "RestedText", + "SafeguardProtectText", + "SandstormBrewedText", + "SandstormHitsText", + "SentAllToMomText", + "SentHalfToMomText", + "SentSomeToMomText", + "SharedPainText", + "ShedLeechSeedText", + "SketchedText", + "SpikesText", + "SpiteEffectText", + "StartPerishText", + "StartedNightmareText", + "StoleText", + "StoringEnergyText", + "SubFadedText", + "SubTookDamageText", + "SuckedHealthText", + "SunGotBrightText", + "SuperEffectiveText", + "TVText", + "TeamRocketOathText", + "Text_BallCaught", + "Text_BattleEffectActivate", + "Text_BattleFoeEffectActivate", + "Text_BattleUser", + "Text_BreedHuh", + "Text_Gained", + "Text_MoveForgetCount", + "Text_NPCTraded", + "Text_PlayedPokeFlute", + "TiedAgainstText", + "TooWeakSubText", + "TookAimText", + "TookDownWithItText", + "TransformedText", + "TransformedTypeText", + "TrashCanText", + "TurnedAwayText", + "UnaffectedText", + "UnleashedEnergyText", + "UnusedRivalLossText", + "UnusedRivalWinText", + "UsedBindText", + "WantsToBattleText", + "WasBurnedText", + "WasDefrostedText", + "WasDisabledText", + "WasFrozenText", + "WasPoisonedText", + "WasSeededText", + "WentToSleepText", + "WhirlpoolTrapText", + "WildCelebiAppearedText", + "WildPokemonAppearedText", + "WindowText", + "WokeUpText", + "WontDropAnymoreText", + "WontObeyText", + "WontRiseAnymoreText", + "WrappedByText", + "_ActorNameText", + "_AlreadyASaveFileText", + "_AlreadySetUpText", + "_AlreadySurfingText", + "_AlreadyUsingStrengthText", + "_AnEggCantHoldAnItemText", + "_AnimationTypeText", + "_AnotherSaveFileText", + "_AreWeGeniusesText", + "_AreYouABoyOrAreYouAGirlText", + "_AskCutText", + "_AskDeleteMoveText", + "_AskFloorElevatorText", + "_AskForgetMoveText", + "_AskGiveNicknameText", + "_AskHeadbuttText", + "_AskItemMoveText", + "_AskQuantityThrowAwayText", + "_AskRockSmashText", + "_AskStrengthText", + "_AskSurfText", + "_AskThrowAwayText", + "_AskWaterfallText", + "_AskWhirlpoolText", + "_BGEventText", + "_BTExcuseMeText", + "_BackAlreadyText", + "_BadgeRequiredText", + "_BallAlmostHadItText", + "_BallAppearedCaughtText", + "_BallBlockedText", + "_BallBoxFullText", + "_BallBrokeFreeText", + "_BallDodgedText", + "_BallDontBeAThiefText", + "_BallMissedText", + "_BallSentToPCText", + "_BallSoCloseText", + "_BargainShopComeAgainText", + "_BargainShopFinalPriceText", + "_BargainShopIntroText", + "_BargainShopNoFundsText", + "_BargainShopPackFullText", + "_BargainShopSoldOutText", + "_BargainShopThanksText", + "_BattleDugText", + "_BattleFlewText", + "_BattleGlowingText", + "_BattleLoweredHeadText", + "_BattleMadeWhirlwindText", + "_BattleMonNickCommaText", + "_BattleMonNicknameText", + "_BattleStatFellText", + "_BattleStatSharplyFellText", + "_BattleStatWentUpText", + "_BattleStatWentWayUpText", + "_BattleTookSunlightText", + "_BattleTowerReturnWhenReadyText", + "_BenFernText1", + "_BenFernText2A", + "_BenFernText2B", + "_BenFernText3A", + "_BenFernText3B", + "_BenIntroText1", + "_BenIntroText2", + "_BenIntroText3", + "_BidsFarewellToMonText", + "_BlindingFlashText", + "_BlueCardBalanceText", + "_BoostedExpPointsText", + "_BootedHMText", + "_BootedTMText", + "_BouldersMayMoveText", + "_BouldersMoveText", + "_BreedAppearsToCareForText", + "_BreedAskNicknameText", + "_BreedBrimmingWithEnergyText", + "_BreedClearboxText", + "_BreedEggHatchText", + "_BreedFriendlyText", + "_BreedNoInterestText", + "_BreedShowsInterestText", + "_BreedingIsNotPossibleText", + "_BuenaAskWhichPrizeText", + "_BuenaComeAgainText", + "_BuenaHereYouGoText", + "_BuenaIsThatRightText", + "_BuenaNoRoomText", + "_BuenaNotEnoughPointsText", + "_BuenaOffTheAirText", + "_BuenaRadioMidnightText1", + "_BuenaRadioMidnightText10", + "_BuenaRadioMidnightText2", + "_BuenaRadioMidnightText3", + "_BuenaRadioMidnightText4", + "_BuenaRadioMidnightText5", + "_BuenaRadioMidnightText6", + "_BuenaRadioMidnightText7", + "_BuenaRadioMidnightText8", + "_BuenaRadioMidnightText9", + "_BuenaRadioText1", + "_BuenaRadioText2", + "_BuenaRadioText3", + "_BuenaRadioText4", + "_BuenaRadioText5", + "_BuenaRadioText6", + "_BuenaRadioText7", + "_BugCatchingContestIsOverText", + "_BugCatchingContestTimeUpText", + "_BurnWasHealedText", + "_ButNoSpaceText", + "_CameToItsSensesText", + "_CanCutText", + "_CantAcceptEggText", + "_CantCarryItemText", + "_CantGetOffBikeText", + "_CantRegisterText", + "_CantSurfText", + "_CantUseDigText", + "_CantUseItemText", + "_CantUseTeleportText", + "_CardFlipChooseACardText", + "_CardFlipDarnText", + "_CardFlipNotEnoughCoinsText", + "_CardFlipPlaceYourBetText", + "_CardFlipPlayAgainText", + "_CardFlipPlayWithThreeCoinsText", + "_CardFlipShuffledText", + "_CardFlipYeahText", + "_CardFolderOpenText", + "_CardWasListedText", + "_CaughtAskNicknameText", + "_ChangeBoxSaveText", + "_ChangeWhichNumberText", + "_CheckCDMAAdapterText", + "_CheckCellPhoneAdapterText", + "_CheckDDIPHSAdapterText", + "_CheckDOCOMOPHSAdapterText", + "_CheckMobileAdapterText", + "_ClearAllSaveDataText", + "_ClearTimeLimitText", + "_ClockHasResetText", + "_ClockIsThisOKText", + "_ClockSetWithControlPadText", + "_ClockTimeMayBeWrongText", + "_ClosingLinkText", + "_CoinCaseCountText", + "_ComeAgainText", + "_ComeBackLaterText", + "_ComeBackText", + "_CompatibilityShouldTheyBreedText", + "_ConfirmPasscodeText", + "_CongratulationsYourPokemonText", + "_ContainedMoveText", + "_ContestAlreadyCaughtText", + "_ContestAskSwitchText", + "_ContestCaughtMonText", + "_ContestJudging_FirstPlaceScoreText", + "_ContestJudging_FirstPlaceText", + "_ContestJudging_SecondPlaceScoreText", + "_ContestJudging_SecondPlaceText", + "_ContestJudging_ThirdPlaceScoreText", + "_ContestJudging_ThirdPlaceText", + "_CoordinatesEventText", + "_CorruptedEventText", + "_CuredOfPoisonText", + "_CutNothingText", + "_DSTIsThatOKText", + "_DayCareLadyIntroEggText", + "_DayCareLadyIntroText", + "_DayCareManIntroEggText", + "_DayCareManIntroText", + "_DaycareDummyText", + "_DeleteSavedLoginPasswordText", + "_DeletedTheLoginPasswordText", + "_DeleterAskWhichMonText", + "_DeleterAskWhichMoveText", + "_DeleterEggText", + "_DeleterForgotMoveText", + "_DeleterIntroText", + "_DeleterNoComeAgainText", + "_DidNotLearnMoveText", + "_DoItMonText", + "_DoesntConcernABoyText", + "_EggDoesNotQualifyText", + "_EggPhotoText", + "_EmptyMailboxText", + "_EndText", + "_EndUsedMove1Text", + "_EndUsedMove2Text", + "_EndUsedMove3Text", + "_EndUsedMove4Text", + "_EndUsedMove5Text", + "_EnemyUsedOnText", + "_EnemyWithdrewText", + "_EnterNewPasscodeText", + "_EnterPasscodeText", + "_EnterTheAmountText", + "_EnterTheIDNoText", + "_EvolvedIntoText", + "_EvolvingText", + "_ExcuseMeYoureNotReadyText", + "_ExpPointsText", + "_FernIntroText1", + "_FernIntroText2", + "_FluteWakeUpText", + "_ForABoyText", + "_ForAGirlText", + "_ForYourMonSendsText", + "_ForYourMonWillTradeText", + "_FoundAnEggText", + "_FoundItemText", + "_FourZerosInvalidText", + "_FruitBearingTreeText", + "_FruitPackIsFullText", + "_GearEllipseText", + "_GearOutOfServiceText", + "_GearTodayText", + "_GoForItMonText", + "_GoMonText", + "_GoodComeBackText", + "_GotBackMonText", + "_GotOffBikeText", + "_GotOnBikeText", + "_GrewToLevelText", + "_HaveNoRoomText", + "_HeadbuttNothingText", + "_HealthReturnedText", + "_HerbShopLadyIntroText", + "_HerbalLadyComeAgainText", + "_HerbalLadyFinalPriceText", + "_HerbalLadyHowManyText", + "_HerbalLadyNoMoneyText", + "_HerbalLadyPackFullText", + "_HerbalLadyThanksText", + "_HeyItsFruitText", + "_HoldStillText", + "_HowManyShouldIMakeText", + "_HugeWaterfallText", + "_IllKeepItThanksText", + "_IllRaiseYourMonText", + "_IncorrectPasscodeText", + "_IsThisOKText", + "_ItemBelongsToSomeoneElseText", + "_ItemCantGetOnText", + "_ItemCantHeldText", + "_ItemCantUseOnEggText", + "_ItemCantUseOnMonText", + "_ItemGotOffText", + "_ItemGotOnText", + "_ItemLooksBitterText", + "_ItemOakWarningText", + "_ItemStatRoseText", + "_ItemStorageFullText", + "_ItemUsedText", + "_ItemWontHaveEffectText", + "_ItemfinderItemNearbyText", + "_ItemfinderNopeText", + "_ItemsDiscardedText", + "_ItemsOakWarningText", + "_ItemsThrowAwayText", + "_ItemsTooImportantText", + "_ItemsTossOutHowManyText", + "_ItsGoingToHatchText", + "_JustSawSomeRareMonText", + "_KarpGuruRecordText", + "_KnowsMoveText", + "_LC_DragText1", + "_LC_DragText2", + "_LC_Text1", + "_LC_Text10", + "_LC_Text11", + "_LC_Text2", + "_LC_Text3", + "_LC_Text4", + "_LC_Text5", + "_LC_Text6", + "_LC_Text7", + "_LC_Text8", + "_LC_Text9", + "_LastHealthyMonText", + "_LearnedMoveText", + "_LeftWithDayCareLadyText", + "_LeftWithDayCareManText", + "_LinkAbnormalMonText", + "_LinkAskTradeForText", + "_LinkTerminatedText", + "_LinkTimeoutText", + "_LinkTradeCantBattleText", + "_LookAdorableDecoText", + "_LookClefairyPosterText", + "_LookGiantDecoText", + "_LookJigglypuffPosterText", + "_LookPikachuPosterText", + "_LookTownMapText", + "_LuckyNumberMatchPCText", + "_LuckyNumberMatchPartyText", + "_MagikarpGuruMeasureText", + "_MailAlreadyHoldingItemText", + "_MailAskSendToPCText", + "_MailClearedPutAwayText", + "_MailDetachedText", + "_MailEggText", + "_MailLoseMessageText", + "_MailMessageLostText", + "_MailMovedFromBoxText", + "_MailNoSpaceText", + "_MailPackFullText", + "_MailSentToPCText", + "_MailboxFullText", + "_MainMenuTimeUnknownText", + "_MartAskMoreText", + "_MartBoughtText", + "_MartCantBuyText", + "_MartComeAgainText", + "_MartFinalPriceText", + "_MartHowManyText", + "_MartNoMoneyText", + "_MartPackFullText", + "_MartSellHowManyText", + "_MartSellPriceText", + "_MartThanksText", + "_MartWelcomeText", + "_MayPassWhirlpoolText", + "_MayRegisterItemText", + "_MaySmashText", + "_MemoryGameDarnText", + "_MemoryGameYeahText", + "_MobileBattleLessThanOneMinuteLeftText", + "_MobileBattleMoreInfoText", + "_MobileBattleMustPickThreeMonText", + "_MobileBattleNoTimeLeftForLinkingText", + "_MobileBattleRemainingTimeText", + "_MobileBattleRulesText", + "_MobileCardFolderAskDeleteOldText", + "_MobileCardFolderAskDeleteText", + "_MobileCardFolderAskOpenOldText", + "_MobileCardFolderDeleteAreYouSureText", + "_MobileCardFolderDeletedText", + "_MobileCardFolderFinishRegisteringCardsText", + "_MobileCardFolderIntro1Text", + "_MobileCardFolderIntro2Text", + "_MobileCardFolderIntro3Text", + "_MobileCardFolderIntro4Text", + "_MobileForPartnersMonText", + "_MobileOnlyThreeMonMayEnterText", + "_MobilePickThreeMonForBattleText", + "_MobilePlayerWillTradeMonText", + "_MobilePlayersMonTrade2Text", + "_MobilePlayersMonTradeText", + "_MobileStadiumEntryText", + "_MobileStadiumSuccessText", + "_MobileTakeGoodCareOfMonText", + "_MobileTakeGoodCareOfText", + "_MobileTradeCameBackText", + "_MobileUseTheseThreeMonText", + "_MomBankWhatDoYouWantToDoText", + "_MomBoughtWithYourMoneyText", + "_MomFoundADollText", + "_MomFoundAnItemText", + "_MomHaventSavedThatMuchText", + "_MomHiHowAreYouText", + "_MomInsufficientFundsInWalletText", + "_MomIsThisAboutYourMoneyText", + "_MomItsInPCText", + "_MomItsInYourRoomText", + "_MomJustDoWhatYouCanText", + "_MomLeavingText1", + "_MomLeavingText2", + "_MomLeavingText3", + "_MomLostGearBookletText", + "_MomNotEnoughRoomInBankText", + "_MomNotEnoughRoomInWalletText", + "_MomSaveMoneyText", + "_MomStartSavingMoneyText", + "_MomStoreMoneyText", + "_MomStoredMoneyText", + "_MomTakeMoneyText", + "_MomTakenMoneyText", + "_MonNameBidsFarewellText", + "_MonNameSentToText", + "_MonNumberText", + "_MonWasSentToText", + "_MoveAskForgetText", + "_MoveBoulderText", + "_MoveCantForgetHMText", + "_MoveForgotText", + "_MoveKnowsOneText", + "_MoveMonWOMailSaveText", + "_MoveNameText", + "_MysteryGiftCanceledText", + "_MysteryGiftCommErrorText", + "_MysteryGiftFiveADayText", + "_MysteryGiftOneADayText", + "_MysteryGiftSentHomeText", + "_MysteryGiftSentText", + "_NPCTradeAfterText1", + "_NPCTradeAfterText2", + "_NPCTradeAfterText3", + "_NPCTradeAfterText4", + "_NPCTradeCableText", + "_NPCTradeCancelText1", + "_NPCTradeCancelText2", + "_NPCTradeCancelText3", + "_NPCTradeCompleteText1", + "_NPCTradeCompleteText2", + "_NPCTradeCompleteText3", + "_NPCTradeCompleteText4", + "_NPCTradeFanfareText", + "_NPCTradeIntroText1", + "_NPCTradeIntroText2", + "_NPCTradeIntroText3", + "_NPCTradeWrongText1", + "_NPCTradeWrongText2", + "_NPCTradeWrongText3", + "_NameCardLinkCancelledText", + "_NameCardLinkCommErrorText", + "_NameCardListedCardText", + "_NameCardNotRegisteredCardText", + "_NameCardReceivedCardText", + "_NameRaterBetterNameText", + "_NameRaterComeAgainText", + "_NameRaterEggText", + "_NameRaterFinishedText", + "_NameRaterHelloText", + "_NameRaterNamedText", + "_NameRaterPerfectNameText", + "_NameRaterSameNameText", + "_NameRaterWhatNameText", + "_NameRaterWhichMonText", + "_NeedAtLeastThreeMonText", + "_NewCardArrivedText", + "_NewDexDataText", + "_NoCoinCaseText", + "_NoCoinsText", + "_NoCyclingText", + "_NoPhotoText", + "_NoPromptText", + "_NoRoomForEggText", + "_NoRoomTMHMText", + "_NotEnoughMoneyText", + "_NotYetText", + "_NothingHereText", + "_NothingToChooseText", + "_NothingToPutAwayText", + "_NothingToSellText", + "_OKComeBackText", + "_OPT_AddictivelyText", + "_OPT_AlmostPoisonouslyText", + "_OPT_AptlyNamedText", + "_OPT_BoldSortOfText", + "_OPT_CuteText", + "_OPT_EvolutionMustBeText", + "_OPT_ExcitingText", + "_OPT_FlippedOutText", + "_OPT_FriendlyText", + "_OPT_FrighteningText", + "_OPT_GroovyText", + "_OPT_GuardedText", + "_OPT_HeartMeltinglyText", + "_OPT_HotHotHotText", + "_OPT_InspiringText", + "_OPT_IntroText1", + "_OPT_IntroText2", + "_OPT_IntroText3", + "_OPT_LooksInWaterText", + "_OPT_LovelyText", + "_OPT_MaryText1", + "_OPT_MischievouslyText", + "_OPT_OakText1", + "_OPT_OakText2", + "_OPT_OakText3", + "_OPT_PleasantText", + "_OPT_PokemonChannelText", + "_OPT_PowerfulText", + "_OPT_ProvocativelyText", + "_OPT_SensuallyText", + "_OPT_SpeedyText", + "_OPT_StimulatingText", + "_OPT_SuaveDebonairText", + "_OPT_SweetAdorablyText", + "_OPT_TopicallyText", + "_OPT_UnbearablyText", + "_OPT_UndeniablyKindOfText", + "_OPT_WeirdText", + "_OPT_WigglySlicklyText", + "_OPT_WowImpressivelyText", + "_OTSendsText", + "_OakPCText1", + "_OakPCText2", + "_OakPCText3", + "_OakPCText4", + "_OakRating01", + "_OakRating02", + "_OakRating03", + "_OakRating04", + "_OakRating05", + "_OakRating06", + "_OakRating07", + "_OakRating08", + "_OakRating09", + "_OakRating10", + "_OakRating11", + "_OakRating12", + "_OakRating13", + "_OakRating14", + "_OakRating15", + "_OakRating16", + "_OakRating17", + "_OakRating18", + "_OakRating19", + "_OakText1", + "_OakText2", + "_OakText3", + "_OakText4", + "_OakText5", + "_OakText6", + "_OakText7", + "_OakThisIsntTheTimeText", + "_OakTimeHoursQuestionMarkText", + "_OakTimeHowManyMinutesText", + "_OakTimeIsItText", + "_OakTimeMinutesQuestionMarkText", + "_OakTimeOversleptText", + "_OakTimeSoDarkText", + "_OakTimeWhatDayIsItText", + "_OakTimeWhatHoursText", + "_OakTimeWhatTimeIsItText", + "_OakTimeWhoaMinutesText", + "_OakTimeWokeUpText", + "_OakTimeYikesText", + "_ObjectEventText", + "_ObtainedFruitText", + "_ObtainedTheVoltorbBadgeText", + "_OhFineThenText", + "_OnlyOneMonText", + "_OnlyThreeMonMayBeEnteredText", + "_PCCantDepositLastMonText", + "_PCCantTakeText", + "_PCGottaHavePokemonText", + "_PCMonHoldingMailText", + "_PCNoSingleMonText", + "_PCWhatText", + "_PPIsMaxedOutText", + "_PPRestoredText", + "_PPsIncreasedText", + "_PackEmptyText", + "_PackNoItemText", + "_PasscodeSetText", + "_PasscodesNotSameText", + "_PasswordAskEnterText", + "_PasswordAskResetClockText", + "_PasswordAskResetText", + "_PasswordWrongText", + "_PerfectHeresYourMonText", + "_PharmacyComeAgainText", + "_PharmacyFinalPriceText", + "_PharmacyHowManyText", + "_PharmacyIntroText", + "_PharmacyNoMoneyText", + "_PharmacyPackFullText", + "_PharmacyThanksText", + "_PhoneClickText", + "_PhoneEllipseText", + "_PhoneJustTalkToThemText", + "_PhoneOutOfAreaText", + "_PhoneThankYouText", + "_PhoneWrongNumberText", + "_PickErrorPacketText", + "_PickThreeMonForMobileBattleText", + "_PlayedFluteText", + "_PlayerFoundItemText", + "_PlayersPCAskWhatDoText", + "_PlayersPCDepositItemsText", + "_PlayersPCHowManyDepositText", + "_PlayersPCHowManyWithdrawText", + "_PlayersPCNoItemsText", + "_PlayersPCNoRoomDepositText", + "_PlayersPCNoRoomWithdrawText", + "_PlayersPCTurnOnText", + "_PlayersPCWithdrewItemsText", + "_PleaseTryAgainTomorrowText", + "_PnP_BoldText", + "_PnP_CoolText", + "_PnP_CuteText", + "_PnP_GreatText", + "_PnP_HappyText", + "_PnP_InspiringText", + "_PnP_LazyText", + "_PnP_MyTypeText", + "_PnP_NoisyText", + "_PnP_OddText", + "_PnP_PickyText", + "_PnP_PrecociousText", + "_PnP_RightForMeText", + "_PnP_SoSoText", + "_PnP_SortOfOKText", + "_PnP_Text1", + "_PnP_Text2", + "_PnP_Text3", + "_PnP_Text4", + "_PnP_Text5", + "_PnP_WeirdText", + "_PocketIsFullText", + "_PoisonFaintText", + "_PoisonWhiteoutText", + "_PokecenterBillsPCText", + "_PokecenterOaksPCText", + "_PokecenterPCCantUseText", + "_PokecenterPCOaksClosedText", + "_PokecenterPCTurnOnText", + "_PokecenterPCWhoseText", + "_PokecenterPlayersPCText", + "_PokedexShowText", + "_PokegearAskDeleteText", + "_PokegearAskWhoCallText", + "_PokegearPressButtonText", + "_PokemonAskSwapItemText", + "_PokemonHoldItemText", + "_PokemonNotEnoughHPText", + "_PokemonNotHoldingText", + "_PokemonRemoveMailText", + "_PokemonSwapItemText", + "_PokemonTookItemText", + "_PrestoAllDoneText", + "_PutAwayAndSetUpText", + "_PutAwayTheDecoText", + "_PutCardInCardFolderText", + "_PutItemInPocketText", + "_RaiseThePPOfWhichMoveText", + "_ReceiveItemText", + "_ReceivedEggText", + "_ReceivedItemText", + "_ReceivedTMHMText", + "_RecoveredSomeHPText", + "_RegisteredItemText", + "_RemainingTimeText", + "_RemoveMailText", + "_RepelUsedEarlierIsStillInEffectText", + "_RepelWoreOffText", + "_RestoreThePPOfWhichMoveText", + "_RetrieveMysteryGiftText", + "_RevitalizedText", + "_RidOfParalysisText", + "_RocketRadioText1", + "_RocketRadioText10", + "_RocketRadioText2", + "_RocketRadioText3", + "_RocketRadioText4", + "_RocketRadioText5", + "_RocketRadioText6", + "_RocketRadioText7", + "_RocketRadioText8", + "_RocketRadioText9", + "_RodBiteText", + "_RodNothingText", + "_SaveFileCorruptedText", + "_SavedTheGameText", + "_SavingDontTurnOffThePowerText", + "_SavingRecordText", + "_SeerCantTellAThingText", + "_SeerDoNothingText", + "_SeerEggText", + "_SeerImpressedText", + "_SeerMightyText", + "_SeerMoreCareText", + "_SeerMoreConfidentText", + "_SeerMuchStrengthText", + "_SeerNameLocationText", + "_SeerNoLocationText", + "_SeerSeeAllText", + "_SeerTimeLevelText", + "_SeerTradeText", + "_SentTrophyHomeText", + "_SetUpTheDecoText", + "_SlotsBetHowManyCoinsText", + "_SlotsDarnText", + "_SlotsLinedUpText", + "_SlotsNotEnoughCoinsText", + "_SlotsPlayAgainText", + "_SlotsRanOutOfCoinsText", + "_SlotsStartText", + "_SpaceSpaceColonText", + "_SquirtbottleNothingText", + "_StartMenuContestEndText", + "_StartText", + "_StartingLinkText", + "_StopLearningMoveText", + "_StoppedEvolvingText", + "_SweetScentNothingText", + "_TMHMNotCompatibleText", + "_TakeGoodCareOfEggText", + "_TakeGoodCareOfMonText", + "_TeleportReturnText", + "_TestEventText", + "_ThatCantBeUsedRightNowText", + "_ThatItemCantBePutInThePackText", + "_ThatsEnoughComeBackText", + "_TheBoxIsFullText", + "_TheItemWasPutInThePackText", + "_TheMonMustAllBeDifferentKindsText", + "_TheMonMustNotHoldTheSameItemsText", + "_ThePasswordIsText", + "_ThereIsNoEggText", + "_ThereIsNothingConnectedText", + "_ThrewAwayText", + "_TimeAskOkayText", + "_TimeLimitWasClearedText", + "_TimesetAskAdjustDSTText", + "_TimesetAskDSTText", + "_TimesetAskNotDSTText", + "_TimesetDSTText", + "_TimesetNotDSTText", + "_TradingMonForOTMonText", + "_TrainerRankingExplanationText", + "_TrainerRankingNoDataText", + "_TryAgainUsingSameSettingsText", + "_UnusedNothingHereText", + "_UseCutText", + "_UseDigText", + "_UseEscapeRopeText", + "_UseHeadbuttText", + "_UseRockSmashText", + "_UseSacredAshText", + "_UseStrengthText", + "_UseSweetScentText", + "_UseWaterfallText", + "_UseWhirlpoolText", + "_UsedInsteadText", + "_UsedMove1Text", + "_UsedMove2Text", + "_UsedSurfText", + "_WaitButtonText", + "_WantAQuickMobileBattleText", + "_WantToRushThroughAMobileBattleText", + "_WarpingText", + "_WasDefrostedText", + "_WasSentToBillsPCText", + "_WhatShouldIRaiseText", + "_WhichApricornText", + "_WhichMonPhotoText", + "_WhichSidePutAwayText", + "_WhichSidePutOnText", + "_WhitedOutText", + "_WillTradeText", + "_WillYouPlayWithMonText", + "_WindowAreaExceededErrorText", + "_WindowPoppingErrorText", + "_WokeUpText", + "_WouldYouLikeToMobileBattleText", + "_WouldYouLikeToSaveTheGameText", + "_YesPromptText", + "_YouCantTakeAnEggText", + "_YouCantUseItInABattleText", + "_YouDontHaveAMonText", + "_YouNeedTwoMonForBreedingText", + "_YourFoesWeakGetmMonText", + "_YourFriendIsNotReadyText", + "_YourMonHasGrownText", + "_YourMonsHPWasHealedText" + ] + }, + "tilesets": { + "TILESET_AERODACTYL_WORD_ROOM": {}, + "TILESET_BATTLE_TOWER_INSIDE": {}, + "TILESET_BATTLE_TOWER_OUTSIDE": {}, + "TILESET_BETA_WORD_ROOM": {}, + "TILESET_CAVE": {}, + "TILESET_CHAMPIONS_ROOM": {}, + "TILESET_DARK_CAVE": {}, + "TILESET_ELITE_FOUR_ROOM": {}, + "TILESET_FACILITY": {}, + "TILESET_FOREST": {}, + "TILESET_GAME_CORNER": {}, + "TILESET_GATE": {}, + "TILESET_HOUSE": {}, + "TILESET_HO_OH_WORD_ROOM": {}, + "TILESET_ICE_PATH": {}, + "TILESET_JOHTO": {}, + "TILESET_JOHTO_MODERN": {}, + "TILESET_KABUTO_WORD_ROOM": {}, + "TILESET_KANTO": {}, + "TILESET_LAB": {}, + "TILESET_LIGHTHOUSE": {}, + "TILESET_MANSION": {}, + "TILESET_MART": {}, + "TILESET_OMANYTE_WORD_ROOM": {}, + "TILESET_PARK": {}, + "TILESET_PLAYERS_HOUSE": {}, + "TILESET_PLAYERS_ROOM": {}, + "TILESET_POKECENTER": {}, + "TILESET_POKECOM_CENTER": {}, + "TILESET_PORT": {}, + "TILESET_RADIO_TOWER": {}, + "TILESET_RUINS_OF_ALPH": {}, + "TILESET_TOWER": {}, + "TILESET_TRADITIONAL_HOUSE": {}, + "TILESET_TRAIN_STATION": {}, + "TILESET_UNDERGROUND": {} + }, + "unownCharmap": { + "0": "A", + "10": "F", + "100": "-", + "12": "G", + "14": "H", + "2": "B", + "255": "@", + "32": "I", + "34": "J", + "36": "K", + "38": "L", + "4": "C", + "40": "M", + "42": "N", + "44": "O", + "46": "P", + "6": "D", + "64": "Q", + "66": "R", + "68": "S", + "70": "T", + "72": "U", + "74": "V", + "76": "W", + "78": "X", + "8": "E", + "96": "Y", + "98": "Z" + } +} diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5e45e56f..b64816f4 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -103,6 +103,9 @@ local function applyLoaded(path, statusVerb) S.loadError = false S.allowSave = true end + if Gen.of(S.save, S.version) == 2 then + S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots) + end local mapId = Gen.playerMap(S.save) S.mapId = mapId S.dirty = false @@ -169,16 +172,14 @@ function App.load(pathOverride, opts) for _, mod in ipairs(S.mods:status().loaded) do modRoots[#modRoots + 1] = mod.path end + S.modRoots = modRoots if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then - S.events = Catalog.goldEventList(modRoots) + S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots) else S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", nil, modRoots) end applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded") - if Gen.of(S.save, S.version) == 2 then - S.events = Catalog.goldEventList(modRoots) - end end -- Switch to another save file (Open button, drag-drop, or --save arg). diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 6280ea25..2bd27988 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -125,15 +125,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) return sortedKeys(found) end -function Catalog.goldEventList(extraDirs) - local names = {} - local ok, flags = pcall(require, "src.core.gen2.FlagNames") - if ok and flags and flags.events then - for name in pairs(flags.events) do - names[#names + 1] = name - end - end - table.sort(names) +function Catalog.gen2EventList(engine, extraDirs) + local names = require("Gen2Flags").names(engine) local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs) local seen = {} for _, name in ipairs(names) do seen[name] = true end @@ -146,4 +139,8 @@ function Catalog.goldEventList(extraDirs) return names end +function Catalog.goldEventList(extraDirs) + return Catalog.gen2EventList("gs", extraDirs) +end + return Catalog diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 56258a85..3b978f55 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -24,6 +24,37 @@ function Gen.of(save, version) return GameVersion.generation() end +local function versionOf(save, version) + if type(save) == "table" and GameVersion.VERSIONS[save.version] then + return save.version + end + if type(version) == "string" and GameVersion.VERSIONS[version] then + return version + end + return GameVersion.get() +end +Gen.versionOf = versionOf + +function Gen.engineOf(save, version) + return GameVersion.engine(versionOf(save, version)) +end + +function Gen.editionLabel(save, version) + local info = GameVersion.info(versionOf(save, version)) + return tostring((info and info.label) or "GEN 2"):upper() +end + +function Gen.hasCaughtData(save, version) + if Gen.of(save, version) ~= 2 then return false end + return require("src.battle.gen2.Mon").hasCaughtData(versionOf(save, version)) +end + +-- engine/menus/init_gender.asm:23-38 +function Gen.hasPlayerGender(save, version) + if Gen.of(save, version) ~= 2 then return false end + return Gen.engineOf(save, version) == "crystal" +end + function Gen.ofState(S) if not S then return GameVersion.generation() end return Gen.of(S.save, S.version) @@ -110,10 +141,17 @@ function Gen.bindGoldData(data) end function Gen.newGame(version) - if (versionGeneration(version) or GameVersion.generation(version)) == 2 then - return require("src.core.gen2.Save").newGame() + local id = type(version) == "string" and GameVersion.VERSIONS[version] and version + or nil + if (versionGeneration(id) or GameVersion.generation()) == 2 then + local Save2 = require("src.core.gen2.Save") + local save = Save2.newGame({ + playerName = id and Save2.defaultPlayerName(id) or nil, + }) + if id then save.version = id end + return save end - return require("src.core.SaveData").newGame() + return require("src.core.SaveData").newGame({ version = id }) end function Gen.validate(save, data) @@ -223,6 +261,34 @@ function Gen.setCoins(save, amount) end end +-- constants/ram_constants.asm:176-177 +function Gen.playerGender(save) + local stored = save and save.player and save.player.gender + return stored == "female" and "female" or "male" +end + +function Gen.setPlayerGender(save, gender) + save.player = save.player or {} + save.player.gender = (gender == "female") and "female" or "male" + return save.player.gender +end + +-- constants/landmark_constants.asm:3, :111-113 +function Gen.landmarkName(data, index) + index = math.floor(tonumber(index) or 0) + local Mon = require("src.battle.gen2.Mon") + if index == Mon.LANDMARK_EVENT then return "EVENT" end + if index == Mon.LANDMARK_GIFT then return "GIFT" end + if index == 0 then return "UNKNOWN" end + local rows = data and data.gen2Landmarks and data.gen2Landmarks.landmarks + for _, rec in pairs(rows or {}) do + if type(rec) == "table" and rec.index == index then + return (tostring(rec.name or rec.id):gsub("\n", " ")) + end + end + return ("#%d"):format(index) +end + function Gen.dexOwnedKey(save) if Gen.of(save) == 2 then return "caught" end return "owned" @@ -317,14 +383,13 @@ function Gen.toggleBadge(save, id) return not on end -local function goldFlagId(name) - local flags = require("src.core.gen2.FlagNames") - return flags.events and flags.events[name] +local function gen2FlagId(save, name) + return require("Gen2Flags").byName(Gen.engineOf(save))[name] end function Gen.getFlag(save, name) if Gen.of(save) == 2 then - local id = goldFlagId(name) + local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") local ev = Events2.new() @@ -338,7 +403,7 @@ end function Gen.setFlag(save, name, on) if Gen.of(save) == 2 then - local id = goldFlagId(name) + local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") local ev = Events2.new() diff --git a/tools/save-editor/Gen2Flags.lua b/tools/save-editor/Gen2Flags.lua new file mode 100644 index 00000000..9de49a9d --- /dev/null +++ b/tools/save-editor/Gen2Flags.lua @@ -0,0 +1,275 @@ +-- ../pokecrystal/constants/event_flags.asm:1 against +-- ../pokegold/constants/event_flags.asm:1 + +local Gen2Flags = {} + +Gen2Flags.CRYSTAL_ADDED = { + EVENT_ALAN_GAVE_FIRE_STONE = 257, + EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 193, + EVENT_AZALEA_TOWN_KURT = 1956, + EVENT_BATTLE_TOWER_BATTLE_ROOM_YOUNGSTER = 1937, + EVENT_BATTLE_TOWER_OPEN_CIVILIANS = 1999, + EVENT_BEAT_BEAUTY_OLIVIA = 1473, + EVENT_BEAT_BUG_CATCHER_WAYNE = 1472, + EVENT_BEAT_CAMPER_QUENTIN = 1475, + EVENT_BEAT_COOLTRAINERF_CARA = 1470, + EVENT_BEAT_COOLTRAINERM_DARIN = 1469, + EVENT_BEAT_FISHER_TULLY = 1108, + EVENT_BEAT_FISHER_TULLY2 = 1119, + EVENT_BEAT_FISHER_TULLY3 = 1120, + EVENT_BEAT_POKEFANF_JAIME = 1474, + EVENT_BEAT_POKEFANM_ALLAN = 1480, + EVENT_BEAT_POKEFANM_REX = 1479, + EVENT_BEAT_POKEMANIAC_MILLER = 1476, + EVENT_BEAT_SAGE_GAKU = 1481, + EVENT_BEAT_SAGE_KOJI = 1483, + EVENT_BEAT_SAGE_MASA = 1482, + EVENT_BEAT_SUPER_NERD_HUGH = 1477, + EVENT_BEAT_SUPER_NERD_MARKUS = 1478, + EVENT_BEAT_SUPER_NERD_STAN = 1409, + EVENT_BEAT_TWINS_LEA_AND_PIA = 1471, + EVENT_BUENA_OFFERED_HER_PHONE_NUMBER = 828, + EVENT_BUENA_OFFERED_HER_PHONE_NUMBER_NO_BLUE_CARD = 670, + EVENT_BUGGING_KURT_TOO_MUCH = 187, + EVENT_BURNED_TOWER_1F_EUSINE = 1893, + EVENT_BURNED_TOWER_1F_HIDDEN_ULTRA_BALL = 255, + EVENT_BURNED_TOWER_1F_HP_UP = 1622, + EVENT_BURNED_TOWER_MORTY = 1892, + EVENT_CAN_GIVE_GS_BALL_TO_KURT = 190, + EVENT_CIANWOOD_CITY_EUSINE = 1965, + EVENT_COPYCAT_1 = 1774, + EVENT_COPYCAT_2 = 1775, + EVENT_DANA_GAVE_THUNDERSTONE = 258, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_DIRE_HIT = 1998, + EVENT_DRAGONS_DEN_B1F_CALCIUM = 1983, + EVENT_DRAGONS_DEN_B1F_MAX_ELIXER = 1984, + EVENT_DRAGON_SHRINE_CLAIR = 1936, + EVENT_ECRUTEAK_CITY_GRAMPS = 1961, + EVENT_ECRUTEAK_GYM_GRAMPS = 1960, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_WANDERING_SAGE = 1969, + EVENT_ERIN_CALCIUM = 827, + EVENT_EUSINE_IN_BURNED_TOWER = 1962, + EVENT_FLORIA_AT_FLOWER_SHOP = 1896, + EVENT_FLORIA_AT_SUDOWOODO = 1897, + EVENT_FOREST_IS_RESTLESS = 192, + EVENT_FOUGHT_EUSINE = 819, + EVENT_FOUGHT_SUICUNE = 821, + EVENT_GAVE_GS_BALL_TO_KURT = 191, + EVENT_GINA_GAVE_LEAF_STONE = 256, + EVENT_GOLDENROD_CITY_MOVE_TUTOR = 1898, + EVENT_GOLDENROD_GAME_CORNER_MOVE_TUTOR = 1899, + EVENT_GOLDENROD_SALE_OFF = 1776, + EVENT_GOLDENROD_SALE_ON = 1777, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_ULTRA_BALL = 1621, + EVENT_GOT_CLEAR_BELL = 120, + EVENT_GOT_DRATINI = 189, + EVENT_GOT_GS_BALL_FROM_GOLDENROD_POKEMON_CENTER = 832, + EVENT_GOT_ODD_EGG = 830, + EVENT_GOT_RAINBOW_WING = 822, + EVENT_HOLE_IN_BURNED_TOWER = 818, + EVENT_HUEY_PROTEIN = 823, + EVENT_ICE_PATH_1F_PROTEIN = 1982, + EVENT_ILEX_FOREST_ANTIDOTE = 1978, + EVENT_ILEX_FOREST_ETHER = 1979, + EVENT_ILEX_FOREST_FARFETCHD = 1769, + EVENT_ILEX_FOREST_KURT = 1957, + EVENT_ILEX_FOREST_LASS = 1773, + EVENT_ILEX_FOREST_X_ATTACK = 1977, + EVENT_JOEY_HP_UP = 824, + EVENT_KOJI_ALLOWS_YOU_PASSAGE_TO_TIN_TOWER = 820, + EVENT_KURTS_HOUSE_GRANDDAUGHTER_1 = 1932, + EVENT_KURTS_HOUSE_GRANDDAUGHTER_2 = 1933, + EVENT_LAKE_OF_RAGE_ELIXER = 1605, + EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY = 58, + EVENT_MET_BUENA = 829, + EVENT_MET_FLORIA = 185, + EVENT_MOUNT_MORTAR_1F_INSIDE_IRON = 1992, + EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_POTION = 1958, + EVENT_MOUNT_MORTAR_1F_INSIDE_NUGGET = 1959, + EVENT_MOUNT_MORTAR_1F_INSIDE_ULTRA_BALL = 1993, + EVENT_MOUNT_MORTAR_B1F_CARBOS = 1671, + EVENT_MOUNT_MORTAR_B1F_FULL_RESTORE = 1994, + EVENT_MOUNT_MORTAR_B1F_MAX_ETHER = 1995, + EVENT_MOUNT_MORTAR_B1F_PP_UP = 1996, + EVENT_OLIVINE_LIGHTHOUSE_5F_SUPER_REPEL = 1638, + EVENT_PARRY_IRON = 826, + EVENT_PICKED_UP_BERRY_FROM_KABUTO_ITEM_ROOM = 1944, + EVENT_PICKED_UP_CHARCOAL_FROM_HO_OH_ITEM_ROOM = 1943, + EVENT_PICKED_UP_ENERGYPOWDER_FROM_KABUTO_ITEM_ROOM = 1947, + EVENT_PICKED_UP_ENERGY_ROOT_FROM_AERODACTYL_ITEM_ROOM = 1955, + EVENT_PICKED_UP_GOLD_BERRY_FROM_AERODACTYL_ITEM_ROOM = 1952, + EVENT_PICKED_UP_GOLD_BERRY_FROM_HO_OH_ITEM_ROOM = 1940, + EVENT_PICKED_UP_HEAL_POWDER_FROM_AERODACTYL_ITEM_ROOM = 1954, + EVENT_PICKED_UP_HEAL_POWDER_FROM_KABUTO_ITEM_ROOM = 1946, + EVENT_PICKED_UP_MOON_STONE_FROM_AERODACTYL_ITEM_ROOM = 1953, + EVENT_PICKED_UP_MYSTERYBERRY_FROM_HO_OH_ITEM_ROOM = 1941, + EVENT_PICKED_UP_MYSTERYBERRY_FROM_OMANYTE_ITEM_ROOM = 1948, + EVENT_PICKED_UP_MYSTIC_WATER_FROM_OMANYTE_ITEM_ROOM = 1949, + EVENT_PICKED_UP_PSNCUREBERRY_FROM_KABUTO_ITEM_ROOM = 1945, + EVENT_PICKED_UP_REVIVAL_HERB_FROM_HO_OH_ITEM_ROOM = 1942, + EVENT_PICKED_UP_STARDUST_FROM_OMANYTE_ITEM_ROOM = 1950, + EVENT_PICKED_UP_STAR_PIECE_FROM_OMANYTE_ITEM_ROOM = 1951, + EVENT_PLAYERS_HOUSE_1F_NEIGHBOR = 1938, + EVENT_PLAYERS_NEIGHBORS_HOUSE_NEIGHBOR = 1939, + EVENT_RADIO_TOWER_5F_ULTRA_BALL = 1997, + EVENT_RANG_CLEAR_BELL_1 = 1894, + EVENT_RANG_CLEAR_BELL_2 = 1895, + EVENT_ROUTE_30_ANTIDOTE = 1976, + EVENT_ROUTE_31_POTION = 1710, + EVENT_ROUTE_32_REPEL = 1713, + EVENT_ROUTE_34_ILEX_FOREST_GATE_LASS = 1771, + EVENT_ROUTE_34_ILEX_FOREST_GATE_TEACHER_BEHIND_COUNTER = 1770, + EVENT_ROUTE_34_ILEX_FOREST_GATE_TEACHER_IN_WALKWAY = 1772, + EVENT_ROUTE_34_NUGGET = 1980, + EVENT_ROUTE_44_MAX_REPEL = 1981, + EVENT_ROUTE_45_NUGGET = 1720, + EVENT_ROUTE_46_X_SPEED = 1724, + EVENT_RUINS_OF_ALPH_OUTSIDE_TOURIST_FISHER = 1934, + EVENT_RUINS_OF_ALPH_OUTSIDE_TOURIST_YOUNGSTERS = 1935, + EVENT_SAW_SUICUNE_AT_CIANWOOD_CITY = 1966, + EVENT_SAW_SUICUNE_ON_ROUTE_36 = 1968, + EVENT_SAW_SUICUNE_ON_ROUTE_42 = 1967, + EVENT_SET_WHEN_FOUGHT_HO_OH = 1975, + EVENT_SILVER_CAVE_ROOM_1_PROTEIN = 1690, + EVENT_SILVER_CAVE_ROOM_1_ULTRA_BALL = 1985, + EVENT_SILVER_CAVE_ROOM_2_CALCIUM = 1986, + EVENT_SILVER_CAVE_ROOM_2_PP_UP = 1988, + EVENT_SILVER_CAVE_ROOM_2_ULTRA_BALL = 1987, + EVENT_SPROUT_TOWER_2F_X_ACCURACY = 1608, + EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP = 186, + EVENT_TALKED_TO_RUINS_COWARD = 188, + EVENT_TEAM_ROCKET_BASE_B1F_GUARD_SPEC = 1643, + EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL = 1647, + EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN = 1645, + EVENT_TEAM_ROCKET_BASE_B3F_ULTRA_BALL = 1620, + EVENT_TEAM_ROCKET_BASE_B3F_X_SPECIAL = 1646, + EVENT_TIFFANY_GAVE_PINK_BOW = 260, + EVENT_TIN_TOWER_1F_ENTEI = 1971, + EVENT_TIN_TOWER_1F_EUSINE = 1973, + EVENT_TIN_TOWER_1F_RAIKOU = 1972, + EVENT_TIN_TOWER_1F_SUICUNE = 1970, + EVENT_TIN_TOWER_1F_WISE_TRIO_1 = 1974, + EVENT_TIN_TOWER_1F_WISE_TRIO_2 = 1989, + EVENT_TIN_TOWER_4F_PP_UP = 1613, + EVENT_TIN_TOWER_6F_MAX_POTION = 1990, + EVENT_TIN_TOWER_9F_HP_UP = 1991, + EVENT_TULLY_ASKED_FOR_PHONE_NUMBER = 655, + EVENT_TULLY_GAVE_WATER_STONE = 259, + EVENT_UNION_CAVE_1F_POTION = 1628, + EVENT_UNION_CAVE_1F_X_ATTACK = 1627, + EVENT_VANCE_CARBOS = 825, + EVENT_VICTORY_ROAD_HP_UP = 1703, + EVENT_WADE_HAS_BERRY = 811, + EVENT_WADE_HAS_BITTER_BERRY = 814, + EVENT_WADE_HAS_PRZCUREBERRY = 813, + EVENT_WADE_HAS_PSNCUREBERRY = 812, + EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER = 809, + EVENT_WALL_OPENED_IN_HO_OH_CHAMBER = 806, + EVENT_WALL_OPENED_IN_KABUTO_CHAMBER = 807, + EVENT_WALL_OPENED_IN_OMANYTE_CHAMBER = 808, + EVENT_WELCOMED_TO_POKECOM_CENTER = 810, + EVENT_WHIRL_ISLAND_SW_ULTRA_BALL = 1680, + EVENT_WILTON_HAS_GREAT_BALL = 816, + EVENT_WILTON_HAS_POKE_BALL = 817, + EVENT_WILTON_HAS_ULTRA_BALL = 815, + EVENT_WISE_TRIOS_ROOM_WISE_TRIO_1 = 1963, + EVENT_WISE_TRIOS_ROOM_WISE_TRIO_2 = 1964, +} + +Gen2Flags.CRYSTAL_REMOVED = { + EVENT_ALAN_READY_FOR_REMATCH = true, + EVENT_ANTHONY_READY_FOR_REMATCH = true, + EVENT_ARNIE_READY_FOR_REMATCH = true, + EVENT_BEAT_FISHER_CHRIS = true, + EVENT_BEAT_FISHER_CHRIS2 = true, + EVENT_BEAT_FISHER_CHRIS3 = true, + EVENT_BEAT_SUPER_NERD_ERIC_UNUSED = true, + EVENT_BETH_READY_FOR_REMATCH = true, + EVENT_BEVERLY_READY_FOR_REMATCH = true, + EVENT_BRENT_READY_FOR_REMATCH = true, + EVENT_BURNED_TOWER_1F_BURN_HEAL = true, + EVENT_BURNED_TOWER_1F_X_SPEED = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL = true, + EVENT_BURNED_TOWER_B1F_HP_UP = true, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES = true, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL = true, + EVENT_CHAD_READY_FOR_REMATCH = true, + EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER = true, + EVENT_CHRIS_READY_FOR_REMATCH = true, + EVENT_DANA_READY_FOR_REMATCH = true, + EVENT_DEREK_READY_FOR_REMATCH = true, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT = true, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT = true, + EVENT_ERIN_READY_FOR_REMATCH = true, + EVENT_GAVEN_READY_FOR_REMATCH = true, + EVENT_GINA_READY_FOR_REMATCH = true, + EVENT_HUEY_READY_FOR_REMATCH = true, + EVENT_ILEX_FOREST_FARFETCHD_1 = true, + EVENT_ILEX_FOREST_FARFETCHD_10 = true, + EVENT_ILEX_FOREST_FARFETCHD_2 = true, + EVENT_ILEX_FOREST_FARFETCHD_3 = true, + EVENT_ILEX_FOREST_FARFETCHD_4 = true, + EVENT_ILEX_FOREST_FARFETCHD_5 = true, + EVENT_ILEX_FOREST_FARFETCHD_6 = true, + EVENT_ILEX_FOREST_FARFETCHD_7 = true, + EVENT_ILEX_FOREST_FARFETCHD_8 = true, + EVENT_ILEX_FOREST_FARFETCHD_9 = true, + EVENT_IRWIN_READY_FOR_REMATCH = true, + EVENT_JACK_READY_FOR_REMATCH = true, + EVENT_JOEY_READY_FOR_REMATCH = true, + EVENT_JOSE_READY_FOR_REMATCH = true, + EVENT_KENJI_READY_FOR_REMATCH = true, + EVENT_LAKE_OF_RAGE_ETHER_ON_STANDBY = true, + EVENT_LAKE_OF_RAGE_MAX_ETHER = true, + EVENT_LIZ_READY_FOR_REMATCH = true, + EVENT_MOUNT_MORTAR_B1F_FULL_HEAL = true, + EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL = true, + EVENT_PARRY_READY_FOR_REMATCH = true, + EVENT_RALPH_READY_FOR_REMATCH = true, + EVENT_REENA_READY_FOR_REMATCH = true, + EVENT_ROUTE_31_ANTIDOTE = true, + EVENT_ROUTE_32_POTION = true, + EVENT_ROUTE_45_X_SPECIAL = true, + EVENT_ROUTE_46_DIRE_HIT = true, + EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY = true, + EVENT_SPROUT_TOWER_2F_X_DEFEND = true, + EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY = true, + EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT = true, + EVENT_TIFFANY_READY_FOR_REMATCH = true, + EVENT_TIN_TOWER_4F_SUPER_POTION = true, + EVENT_TODD_READY_FOR_REMATCH = true, + EVENT_VANCE_READY_FOR_REMATCH = true, + EVENT_VICTORY_ROAD_X_SPECIAL = true, + EVENT_WADE_READY_FOR_REMATCH = true, + EVENT_WHIRL_ISLAND_SW_GUARD_SPEC = true, + EVENT_WILTON_READY_FOR_REMATCH = true, +} + +local cache = {} + +function Gen2Flags.byName(engine) + engine = (engine == "crystal") and "crystal" or "gs" + if cache[engine] then return cache[engine] end + local ids = {} + local ok, flags = pcall(require, "src.core.gen2.FlagNames") + if ok and type(flags) == "table" and type(flags.events) == "table" then + for name, id in pairs(flags.events) do ids[name] = id end + end + if engine == "crystal" then + for name in pairs(Gen2Flags.CRYSTAL_REMOVED) do ids[name] = nil end + for name, id in pairs(Gen2Flags.CRYSTAL_ADDED) do ids[name] = id end + end + cache[engine] = ids + return ids +end + +function Gen2Flags.names(engine) + local names = {} + for name in pairs(Gen2Flags.byName(engine)) do names[#names + 1] = name end + table.sort(names) + return names +end + +return Gen2Flags diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 86276d76..c5e2a33d 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -1141,4 +1141,74 @@ function Ops.setPokerus(S, mon, value) return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want)) end +-- engine/pokemon/caught_data.asm:169-172 +Ops.CAUGHT_TIMES = { "UNKNOWN", "MORN", "DAY", "NITE" } + +local function caughtGuard(S, mon) + if not mon then return false end + if not Gen.hasCaughtData(S.save, S.version) then + return Ops.say(S, "This game has no caught data") + end + return true +end + +function Ops.setCaughtTime(S, mon, value) + if not caughtGuard(S, mon) then return false end + local want = clamp(math.floor(tonumber(value) or 0), 0, 3) + if want == (mon.caughtTime or 0) then + return Ops.say(S, ("Caught time is already %s"):format(Ops.CAUGHT_TIMES[want + 1])) + end + mon.caughtTime = want + return Ops.mark(S, ("%s caught time %s") + :format(mon.species, Ops.CAUGHT_TIMES[want + 1])) +end + +-- constants/pokemon_data_constants.asm:120-121 +function Ops.setCaughtLevel(S, mon, value) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local want = clamp(math.floor(tonumber(value) or 0), 0, Mon.CAUGHT_LEVEL_MASK) + if want == (mon.caughtLevel or 0) then + return Ops.say(S, ("Caught level is already %d"):format(want)) + end + mon.caughtLevel = want + return Ops.mark(S, ("%s caught level %d"):format(mon.species, want)) +end + +-- constants/landmark_constants.asm:111-113 +function Ops.setCaughtLocation(S, mon, value) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local span = Mon.CAUGHT_LOCATION_MASK + 1 + local want = math.floor(tonumber(value) or 0) % span + if want == (mon.caughtLocation or 0) then + return Ops.say(S, ("Caught location is already %s") + :format(Gen.landmarkName(S.data, want))) + end + mon.caughtLocation = want + return Ops.mark(S, ("%s caught at %s") + :format(mon.species, Gen.landmarkName(S.data, want))) +end + +function Ops.setCaughtByGender(S, mon, gender) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local want = Mon.caughtGenderOf(gender) + if want == mon.caughtByGender then + return Ops.say(S, ("Caught by is already %s"):format(tostring(want or "none"))) + end + mon.caughtByGender = want + return Ops.mark(S, ("%s caught by %s"):format(mon.species, tostring(want or "none"))) +end + +function Ops.setPlayerGender(S, gender) + if not Gen.hasPlayerGender(S.save, S.version) then + return Ops.say(S, "This game has no player gender") + end + if Gen.playerGender(S.save) == gender then + return Ops.say(S, ("Player is already %s"):format(tostring(gender))) + end + return Ops.mark(S, ("Player gender %s"):format(Gen.setPlayerGender(S.save, gender))) +end + return Ops diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 258e32d2..534d5411 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -26,6 +26,7 @@ function State.new() -- App.load's opts; nil in a bare `love . --editor` run. version = nil, slotId = nil, + modRoots = nil, -- Hosted inside the launcher process (Edit on a save row) rather than a -- standalone `--editor` window: Close returns to the launcher instead of -- quitting, and App calls onClose() to do it. diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index e43a8282..94db78bb 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -101,6 +101,31 @@ local function drawMoney(S, Kit, x, y, w, h) end end +-- engine/menus/init_gender.asm:55-56 +local GENDERS = { { "BOY", "male" }, { "GIRL", "female" } } + +local function trainerHeight(Kit, s, pad) + return pad * 2 + Kit.textHeight("caption") + 10 * s + 28 * s +end + +local function drawTrainer(S, Kit, x, y, w, h) + local s = Kit.scale + local pad = 16 * s + Kit.card(x, y, w, h) + Kit.caption(x + pad, y + pad, "TRAINER") + Kit.textRight("mono", tostring((S.save.player and S.save.player.name) or "?"), + x + w - pad, y + pad, PAL.caption) + local cy = y + pad + Kit.textHeight("caption") + 10 * s + local chipW = (w - 2 * pad - 8 * s) / 2 + local gender = Gen.playerGender(S.save) + for i, pair in ipairs(GENDERS) do + if Kit.chip(x + pad + (i - 1) * (chipW + 8 * s), cy, chipW, 28 * s, + pair[1], gender == pair[2], PAL.green, PAL.steel) then + Ops.setPlayerGender(S, pair[2]) + end + end +end + local BADGE_COLS = 4 local function badgeHeight(S, Kit, s, pad) @@ -273,6 +298,10 @@ function M.draw(S, Kit, x, y, w, h) local listH = 300 * s Kit.pushClip(x, y, w, h) local cy = y - off + if Gen.hasPlayerGender(S.save, S.version) then + local trainerH = trainerHeight(Kit, s, pad) + drawTrainer(S, Kit, x, cy, w, trainerH); cy = cy + trainerH + gap + end drawMoney(S, Kit, x, cy, w, moneyH); cy = cy + moneyH + gap drawPicker(S, Kit, x, cy, w, pickH); cy = cy + pickH + gap drawBadges(S, Kit, x, cy, w, badgeH); cy = cy + badgeH + gap @@ -294,8 +323,14 @@ function M.draw(S, Kit, x, y, w, h) -- made the old panel unusable. local moneyH = moneyHeight(Kit, s, pad, S) local badgeH = badgeHeight(S, Kit, s, pad) - drawMoney(S, Kit, x, y, leftW, moneyH) - drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap) + local topH = 0 + if Gen.hasPlayerGender(S.save, S.version) then + topH = trainerHeight(Kit, s, pad) + gap + drawTrainer(S, Kit, x, y, leftW, topH - gap) + end + drawMoney(S, Kit, x, y + topH, leftW, moneyH) + drawPicker(S, Kit, x, y + topH + moneyH + gap, leftW, + h - topH - moneyH - badgeH - 2 * gap) drawBadges(S, Kit, x, y + h - badgeH, leftW, badgeH) drawBag(S, Kit, bagX, y, listW, h) drawPc(S, Kit, pcX, y, listW, h) diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index b9286048..35757324 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -147,6 +147,66 @@ local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) end end +-- engine/pokemon/caught_data.asm:168-199 +local CAUGHT_BY = { { "-", "none" }, { "BOY", "boy" }, { "GIRL", "girl" } } + +local function drawCaughtRows(S, Kit, mon, cx, y, inner, row) + local s = Kit.scale + local chipH = 22 * s + local gap = 6 * s + local tinyH = Kit.textHeight("tiny") + + Kit.text("tiny", "CAUGHT", cx, y + (row - tinyH) / 2, PAL.caption) + local timeX = cx + 62 * s + local timeW = math.max(34 * s, (cx + inner - timeX - 3 * gap) / 4) + for i, label in ipairs(Ops.CAUGHT_TIMES) do + if Kit.chip(timeX + (i - 1) * (timeW + gap), y + (row - chipH) / 2, + timeW, chipH, label, (mon.caughtTime or 0) == i - 1, PAL.blue, PAL.steel) then + Ops.setCaughtTime(S, mon, i - 1) + end + end + y = y + row + gap + + local btn = 24 * s + local lvX = cx + inner - 2 * btn - gap + if Kit.stepper(lvX, y + (row - btn) / 2, btn, btn, "-", { font = "small" }) then + Ops.setCaughtLevel(S, mon, (mon.caughtLevel or 0) - 1) + end + if Kit.stepper(lvX + btn + gap, y + (row - btn) / 2, btn, btn, "+", + { font = "small" }) then + Ops.setCaughtLevel(S, mon, (mon.caughtLevel or 0) + 1) + end + Kit.textRight("tiny", ("MET LV %d"):format(mon.caughtLevel or 0), lvX - 10 * s, + y + (row - tinyH) / 2, PAL.text) + Kit.text("tiny", "OT", cx, y + (row - tinyH) / 2, PAL.caption) + local otX = cx + 26 * s + local otW = math.max(30 * s, (inner * 0.42 - 26 * s - 2 * gap) / 3) + for i, pair in ipairs(CAUGHT_BY) do + if Kit.chip(otX + (i - 1) * (otW + gap), y + (row - chipH) / 2, otW, chipH, + pair[1], (mon.caughtByGender or "none") == pair[2], PAL.blue, PAL.steel) then + Ops.setCaughtByGender(S, mon, pair[2]) + end + end + y = y + row + gap + + local whereX = cx + inner - 3 * btn - 2 * gap + if Kit.stepper(whereX, y + (row - btn) / 2, btn, btn, "-", { font = "small" }) then + Ops.setCaughtLocation(S, mon, (mon.caughtLocation or 0) - 1) + end + if Kit.stepper(whereX + btn + gap, y + (row - btn) / 2, btn, btn, "+", + { font = "small" }) then + Ops.setCaughtLocation(S, mon, (mon.caughtLocation or 0) + 1) + end + if Kit.button(whereX + 2 * (btn + gap), y + (row - btn) / 2, btn, btn, "0", + { kind = "danger", font = "micro", radius = 6 * s }) then + Ops.setCaughtLocation(S, mon, 0) + end + local where = ("WHERE %s"):format(Gen.landmarkName(S.data, mon.caughtLocation or 0)) + Kit.text("tiny", Kit.ellipsize("tiny", where, whereX - 10 * s - cx), cx, + y + (row - tinyH) / 2, PAL.text) + return y + row + gap +end + local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local s = Kit.scale for slot = 1, 4 do @@ -231,6 +291,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- the field + Set row local extraH = 0 if Gen.ofState(S) == 2 then extraH = 88 * s end + if Gen.hasCaughtData(S.save, S.version) then extraH = extraH + 102 * s end local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s + capH + 10 * s + nickFieldH + 18 * s @@ -341,7 +402,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local colY = statsY + cellH + 18 * s if Gen.ofState(S) == 2 then local extraY = colY - Kit.caption(cx, extraY, "GOLD") + Kit.caption(cx, extraY, Gen.editionLabel(S.save, S.version)) extraY = extraY + capH + 8 * s local row = 28 * s Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text) @@ -371,7 +432,11 @@ function MonEditor.draw(S, Kit, x, y, w, h) if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ") or "gender/shiny follow DVs", cx, extraY, PAL.caption) - colY = extraY + 22 * s + extraY = extraY + 22 * s + if Gen.hasCaughtData(S.save, S.version) then + extraY = drawCaughtRows(S, Kit, mon, cx, extraY, inner, row) + end + colY = extraY end if narrow then -- stacked: DVs first, then moves, then the two actions side by side at